Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
153 changes: 153 additions & 0 deletions README-ko.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# React Native Toast
[![runs with expo](https://img.shields.io/badge/Runs%20with%20Expo-4630EB.svg?style=flat-square&logo=EXPO&labelColor=f3f3f3&logoColor=000)](https://expo.io/) [![GitHub license](https://img.shields.io/github/license/backpackapp-io/react-native-toast)](https://github.com/backpackapp-io/react-native-toast/blob/master/LICENSE) [![npm](https://img.shields.io/badge/types-included-blue?style=flat-square)](https://www.npmjs.com/package/@backpackapp-io/react-native-toast)

[English](./README.md) · 한국어

**React Native Toast**는 [react-hot-toast](https://react-hot-toast.com/docs)를 기반으로 구축된 React Native용 토스트(Toast) 라이브러리입니다.
iOS, Android, Web 환경에서 모두 작동하며, 다중 토스트 표시, 키보드 대응, 스와이프 닫기, 위치 지정, 그리고 Promise 기반 처리와 같은 기능을 제공합니다.

<br />

[video](https://user-images.githubusercontent.com/27066041/180588807-1ca73f29-56d7-4e44-ac0c-9f2e2cdeb94c.mp4)

<br />

### 왜 이 라이브러리를 만들었나요?
아마 이렇게 생각하실 수도 있습니다. (*또 다른 토스트 라이브러리라고? 정말 필요할까?*). 하지만 믿어주세요. **이 라이브러리 하나면 충분합니다.** 제가 직접 앱을 개발하면서 필요한 기능을 모두 담기 위해 만들었고, 사용해보니 완성도가 높아 오픈소스로 공개하게 되었습니다.

## 주요 기능

- **다중 토스트 및 다양한 옵션** 여러 개의 토스트를 동시에 표시하거나, 위치·색상·유형을 각각 다르게 설정할 수 있습니다.
- **키보드 대응** iOS와 Android에서 키보드가 열릴 때 토스트가 자동으로 화면에 잘 보이도록 위치를 조정합니다.
- **스와이프로 닫기**
- **위치 지정 지원** (`top`, `bottom`, `top-left`, `top-right`, `bottom-left`, `bottom-right`)
- **높은 수준의 커스터마이징** (스타일, 크기, 지속 시간 등을 조정하거나, 사용자 정의 컴포넌트를 표시할 수 있습니다.)
- **Promise 지원** (`toast.promise()`를 사용하면 Promise의 상태에 따라 토스트 메시지가 자동으로 업데이트됩니다.)
- **Web 지원**
- **Native Modal 지원**
- **onPress, onShow, onHide 콜백 지원**

# 문서
전체 문서는 [여기](https://nickdebaise.github.io/packages/react-native-toast/)에서 확인할 수 있습니다.

# 시작하기

## 설치
```sh
yarn add @backpackapp-io/react-native-toast
# 또는
npm i @backpackapp-io/react-native-toast
```
#### 필수 의존성
다음 패키지를 함께 설치해야 합니다.
- [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/)
- [react-native-safe-area-context](https://github.com/th3rdwave/react-native-safe-area-context)
- [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/installation)

```sh
yarn add react-native-reanimated react-native-safe-area-context react-native-gesture-handler
```
*각 패키지의 설치 가이드를 반드시 참고하세요.*


*Expo를 사용하는 경우*
```sh
npx expo install react-native-reanimated react-native-safe-area-context react-native-gesture-handler
```
<br />

### 사용 방법
앱의 루트 컴포넌트를 ``<GestureHandlerRootView />`` 및 ``<SafeAreaProvider />``로 감싸고, 루트 레벨에 ``<Toasts />`` 컴포넌트를 추가합니다.

이후 앱의 어느 위치에서든 ``toast("메시지")``를 호출할 수 있습니다.

```js
import { View, StyleSheet, Text } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { toast, Toasts } from '@backpackapp-io/react-native-toast';
import { useEffect } from 'react';

export default function App() {
useEffect(() => {
toast('Hello');
}, []);

return (
<SafeAreaProvider>
<GestureHandlerRootView style={styles.container}>
<View>{/* 앱의 나머지 컴포넌트 */}</View>
<Toasts /> {/* 여기에 추가 */}
</GestureHandlerRootView>
</SafeAreaProvider>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
```

<br />

## 예제

#### Regular Toast
```js
toast("This is my first toast", {
duration: 3000,
});
```

#### Promise Toast
```js
const sleep = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.5) {
resolve({
username: 'Nick',
});
} else {
reject('Username is undefined');
}
}, 2500);
});

toast.promise(
sleep,
{
loading: 'Loading...',
success: (data: any) => 'Welcome ' + data.username,
error: (err) => err.toString(),
},
{
position: ToastPosition.BOTTOM,
}
);
```

#### Loading Toast
```js
const id = toast.loading('I am loading. Dismiss me whenever...');

setTimeout(() => {
toast.dismiss(id);
}, 3000);
```

#### Success Toast
```js
toast.success('Success!', {
width: 300
});
```
#### Error Toast
```js
toast.error('Wow. That Sucked!');
```

<br />
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# React Native Toast
[![runs with expo](https://img.shields.io/badge/Runs%20with%20Expo-4630EB.svg?style=flat-square&logo=EXPO&labelColor=f3f3f3&logoColor=000)](https://expo.io/) [![GitHub license](https://img.shields.io/github/license/backpackapp-io/react-native-toast)](https://github.com/backpackapp-io/react-native-toast/blob/master/LICENSE) [![npm](https://img.shields.io/badge/types-included-blue?style=flat-square)](https://www.npmjs.com/package/@backpackapp-io/react-native-toast)

English · [한국어](./README-ko.md)

A toast library for react-native, built on [react-hot-toast](https://react-hot-toast.com/docs). It supports features such as multiple toasts, keyboard handling, swipe to dismiss, positional toasts, and JS promises. It runs on iOS, android, and web.

<br />
Expand Down
16 changes: 15 additions & 1 deletion __tests__/src/components/Toasts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('<Toasts />', () => {
afterEach(() => {
jest.clearAllMocks();
act(() => {
toast.dismiss();
toast.remove();
});
});

Expand Down Expand Up @@ -135,4 +135,18 @@ describe('<Toasts />', () => {
const toastElement = getByText('Top Toast');
expect(toastElement).toBeTruthy();
});

it('applies toast limit from globalLimit prop', () => {
const { getByText, queryByText } = render(<Toasts globalLimit={2} />);

act(() => {
toast('Toast 1', { id: '1' });
toast('Toast 2', { id: '2' });
toast('Toast 3', { id: '3' });
});

expect(getByText('Toast 2')).toBeTruthy();
expect(getByText('Toast 3')).toBeTruthy();
expect(queryByText('Toast 1')).toBeNull();
});
});
4 changes: 4 additions & 0 deletions src/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@ export const Toast: FC<Props> = ({
zIndex: toast.visible ? 9999 : undefined,
alignItems: 'center',
justifyContent: 'center',
pointerEvents: Platform.select({
web: 'auto',
default: undefined,
}),
},
style,
!toast.disableShadow && ConstructShadow('#181821', 0.15, false),
Expand Down
12 changes: 10 additions & 2 deletions src/components/Toasts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useKeyboard } from '../utils';

type Props = {
overrideDarkMode?: boolean;
overrideScreenReaderEnabled?: boolean;
extraInsets?: ExtraInsets;
onToastShow?: (toast: T) => void;
onToastHide?: (toast: T, reason?: DismissReason) => void;
Expand All @@ -39,13 +40,15 @@ type Props = {
};
globalAnimationType?: ToastAnimationType;
globalAnimationConfig?: ToastAnimationConfig;
globalLimit?: number;
defaultPosition?: ToastPosition;
defaultDuration?: number;
fixAndroidInsets?: boolean;
};

export const Toasts: FunctionComponent<Props> = ({
overrideDarkMode,
overrideScreenReaderEnabled,
extraInsets,
onToastHide,
onToastPress,
Expand All @@ -57,19 +60,21 @@ export const Toasts: FunctionComponent<Props> = ({
globalAnimationConfig,
defaultPosition,
defaultDuration,
globalLimit,
fixAndroidInsets = true,
}) => {
const { toasts, handlers } = useToaster({
providerKey,
duration: defaultDuration,
position: defaultPosition,
animationType: globalAnimationType,
limit: globalLimit,
});
const { startPause, endPause } = handlers;
const insets = useSafeAreaInsets();
const safeAreaFrame = useSafeAreaFrame();
const dimensions = useWindowDimensions();
const isScreenReaderEnabled = useScreenReader();
const isScreenReaderEnabled = useScreenReader(overrideScreenReaderEnabled);
const { keyboardShown: keyboardVisible, keyboardHeight } = useKeyboard();

// Fix for Android bottom inset bug: https://github.com/facebook/react-native/issues/47080
Expand All @@ -92,7 +97,10 @@ export const Toasts: FunctionComponent<Props> = ({
left: insets.left + (extraInsets?.left ?? 0),
right: insets.right + (extraInsets?.right ?? 0),
bottom: insets.bottom + bugFixDelta + (extraInsets?.bottom ?? 0) + 16,
pointerEvents: 'box-none',
pointerEvents: Platform.select({
web: 'none',
default: 'box-none',
}),
}}
>
{toasts.map((t) => (
Expand Down
6 changes: 5 additions & 1 deletion src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const reducer = (state: State, action: Action): State => {
case ActionType.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
toasts: [action.toast, ...state.toasts],
};

case ActionType.UPDATE_TOAST:
Expand Down Expand Up @@ -187,6 +187,7 @@ const defaultTimeouts: {

export const useStore = (toastOptions: DefaultToastOptions = {}): State => {
const [state, setState] = useState<State>(memoryState);

useEffect(() => {
listeners.push(setState);
return () => {
Expand All @@ -197,13 +198,16 @@ export const useStore = (toastOptions: DefaultToastOptions = {}): State => {
};
}, [state]);

const limit = toastOptions?.limit ?? TOAST_LIMIT;

const mergedToasts = state.toasts
.filter(
(t) =>
toastOptions?.providerKey === undefined ||
t.providerKey === toastOptions?.providerKey ||
t.providerKey === 'PERSISTS'
)
.slice(0, limit)
.map((t) => ({
...toastOptions,
...toastOptions[t.type],
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface Toast {
height?: number;
width?: number;
maxWidth?: number;
limit?: number;
onPress?: (toast: Toast) => void;
onHide?: (toast: Toast, reason: DismissReason) => void;
onShow?: (toast: Toast) => void;
Expand Down Expand Up @@ -90,6 +91,7 @@ export type ToastOptions = Partial<
| 'styles'
| 'height'
| 'width'
| 'limit'
| 'customToast'
| 'disableShadow'
| 'providerKey'
Expand Down
32 changes: 28 additions & 4 deletions src/utils/useScreenReader.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';
import { AccessibilityInfo, Platform } from 'react-native';

export const useScreenReader = () => {
/**
* Hook to detect if a screen reader is enabled on the device.
*
* @param override - Optional boolean to override the detected screen reader state.
* When provided, this value will be returned instead of the detected state.
* Useful for testing or forcing a specific screen reader state.
* @returns {boolean} True if screen reader is enabled (or overridden to true), false otherwise.
*
* @example
* // Auto-detect screen reader
* const isScreenReaderEnabled = useScreenReader();
*
* @example
* // Force screen reader to be enabled
* const isScreenReaderEnabled = useScreenReader(true);
*
* @example
* // Force screen reader to be disabled
* const isScreenReaderEnabled = useScreenReader(false);
*/
export const useScreenReader = (override?: boolean) => {
const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false);
useEffect(() => {
AccessibilityInfo.isScreenReaderEnabled()
.then(setIsScreenReaderEnabled)
.then((isEnabled) => {
if (Platform.OS !== 'web') {
setIsScreenReaderEnabled(isEnabled);
}
})
.catch(() => {
setIsScreenReaderEnabled(false);
});
}, []);
return isScreenReaderEnabled;
return override !== undefined ? override : isScreenReaderEnabled;
};

export const announceForAccessibility = (message: string) => {
Expand Down
24 changes: 24 additions & 0 deletions website/docs/components/toasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Set the global animation config for all toasts. This can be overridden by the to
<Toasts globalAnimationConfig={{duration: 500, flingPositionReturnDuration: 200, easing: Easing.elastic(1)}} />
```

### globalLimit
`number | undefined`

Set the global limit for the number of toasts that can be shown at once. When this limit is reached, the oldest toast will be removed to make room for the new one.


### defaultPosition
`ToastPosition | undefined`

Expand Down Expand Up @@ -73,6 +79,24 @@ toast("Quick message", { duration: 2000 });

Override the system dark mode. If a value is supplied (I.e. `true` or `false`), then the toast components will use that value for the dark mode. For example, if `overrideDarkMode = {false}`, dark mode will be disabled, regardless of the system's preferences.

### overrideScreenReaderEnabled
`boolean | undefined`

Override the detected screen reader state. When set, this forces the component to behave as if the screen reader is enabled or disabled, regardless of the actual device state.

- `true`: Component behaves as if screen reader is enabled (toasts will be hidden unless `preventScreenReaderFromHiding` is also `true`)
- `false`: Component behaves as if screen reader is disabled (toasts will always be shown)
- `undefined` (default): Auto-detect screen reader state from the device

This is useful for testing or forcing specific behavior in your app.

```js
// Force toasts to show even if screen reader is enabled
<Toasts overrideScreenReaderEnabled={false} />
```

See the [Accessibility](/features/accessibility#overriding-screen-reader-detection) section for more details.

### extraInsets
`object`

Expand Down
Loading