react-strawberry-toast 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { useSyncExternalStore } from 'react';
5
+ import { Toast } from './toast';
6
+ import { toastStore } from '../core/toast';
7
+ import { getDirection } from '../utils/get-direction';
8
+ import type { Position, NonHeadlessToastState as ToastState } from '../types';
9
+ import { Condition, If, Else } from './condition';
10
+
11
+ const OFFSET = 16;
12
+
13
+ const positionStyle: Record<Position, React.CSSProperties> = {
14
+ 'top-left': {
15
+ top: OFFSET,
16
+ left: OFFSET,
17
+ },
18
+ 'top-center': {
19
+ top: OFFSET,
20
+ left: '50%',
21
+ transform: 'translateX(-50%)',
22
+ },
23
+ 'top-right': {
24
+ top: OFFSET,
25
+ right: OFFSET,
26
+ },
27
+ 'bottom-left': {
28
+ bottom: OFFSET,
29
+ left: OFFSET,
30
+ },
31
+ 'bottom-center': {
32
+ bottom: OFFSET,
33
+ left: '50%',
34
+ transform: 'translateX(-50%)',
35
+ },
36
+ 'bottom-right': {
37
+ bottom: OFFSET,
38
+ right: OFFSET,
39
+ },
40
+ };
41
+
42
+ interface ToastContainerProps {
43
+ position?: Position;
44
+ containerId?: string;
45
+ reverse?: boolean;
46
+ gap?: number;
47
+ }
48
+
49
+ export function ToastContainer({
50
+ position: globalPosition = 'top-center',
51
+ containerId = '',
52
+ gap = 9,
53
+ reverse = false,
54
+ }: ToastContainerProps) {
55
+ const toastList = useSyncExternalStore(
56
+ toastStore.subscribe.bind(toastStore),
57
+ toastStore.getSnapShot.bind(toastStore),
58
+ toastStore.getSnapShot.bind(toastStore)
59
+ );
60
+
61
+ const toastsByPosition: Record<Position, Array<ToastState>> = toastList
62
+ .filter((toast) => toast.containerId === undefined)
63
+ .reduce((acc, toast) => {
64
+ const key = toast.position || globalPosition;
65
+ toast.position = key;
66
+ acc[key] = acc[key] || [];
67
+ acc[key].push(toast);
68
+ return acc;
69
+ }, {} as Record<Position, Array<ToastState>>);
70
+
71
+ return (
72
+ <Condition condition={!!containerId}>
73
+ <If>
74
+ <div
75
+ style={{
76
+ position: 'absolute',
77
+ zIndex: 9999,
78
+ pointerEvents: 'none',
79
+ }}
80
+ >
81
+ <div
82
+ style={{
83
+ pointerEvents: 'auto',
84
+ display: 'flex',
85
+ flexDirection: reverse ? 'column-reverse' : 'column',
86
+ gap,
87
+ }}
88
+ >
89
+ {toastList
90
+ .filter((toast) => toast.containerId === containerId)
91
+ .map((toast) => (
92
+ <Toast
93
+ key={toast.toastId}
94
+ toastProps={toast}
95
+ style={{
96
+ display: 'flex',
97
+ }}
98
+ />
99
+ ))}
100
+ </div>
101
+ </div>
102
+ </If>
103
+ <Else>
104
+ <div
105
+ style={{
106
+ position: 'fixed',
107
+ zIndex: 9999,
108
+ top: OFFSET,
109
+ left: OFFSET,
110
+ right: OFFSET,
111
+ bottom: OFFSET,
112
+ pointerEvents: 'none',
113
+ }}
114
+ >
115
+ {Object.entries(toastsByPosition).map(([position, toasts]) => {
116
+ const style = positionStyle[position as Position];
117
+
118
+ const flexDirection = getDirection({
119
+ position: position as Position,
120
+ reverse,
121
+ });
122
+
123
+ return (
124
+ <div
125
+ key={position}
126
+ data-testid={position}
127
+ style={{
128
+ pointerEvents: 'auto',
129
+ position: 'fixed',
130
+ display: 'flex',
131
+ flexDirection,
132
+ gap,
133
+ ...style,
134
+ }}
135
+ >
136
+ {toasts.map((toast) => (
137
+ <Toast
138
+ key={toast.toastId}
139
+ toastProps={toast}
140
+ style={{
141
+ display: 'flex',
142
+ justifyContent: 'center',
143
+ }}
144
+ />
145
+ ))}
146
+ </div>
147
+ );
148
+ })}
149
+ </div>
150
+ </Else>
151
+ </Condition>
152
+ );
153
+ }
@@ -0,0 +1,96 @@
1
+ import React, { type PropsWithChildren, type ReactNode } from 'react';
2
+ import { STYLE_NAMESPACE } from '../constants';
3
+ import type { ToastType } from '../types';
4
+
5
+ function SuccessSvg() {
6
+ return (
7
+ <svg
8
+ stroke="none"
9
+ fill="none"
10
+ strokeWidth="2"
11
+ viewBox="0 0 24 24"
12
+ strokeLinecap="round"
13
+ strokeLinejoin="round"
14
+ height="22"
15
+ width="22"
16
+ xmlns="http://www.w3.org/2000/svg"
17
+ >
18
+ <path stroke="none" d="M0 0h24v24H0z"></path>
19
+ <path
20
+ d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z"
21
+ strokeWidth="0"
22
+ fill="#1ab173"
23
+ />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ function ErrorSvg() {
29
+ return (
30
+ <svg
31
+ stroke="none"
32
+ fill="#eb2639"
33
+ strokeWidth="2"
34
+ viewBox="0 0 24 24"
35
+ height="22"
36
+ width="22"
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ >
39
+ <path d="M11.953 2C6.465 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.493 2 11.953 2zM13 17h-2v-2h2v2zm0-4h-2V7h2v6z"></path>
40
+ </svg>
41
+ );
42
+ }
43
+
44
+ function WarnSvg() {
45
+ return (
46
+ <svg
47
+ stroke="none"
48
+ fill="#fcba03"
49
+ strokeWidth="0"
50
+ viewBox="0 0 1024 1024"
51
+ height="22"
52
+ width="22"
53
+ xmlns="http://www.w3.org/2000/svg"
54
+ >
55
+ <path d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48zM480 416c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v184c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V416zm32 352a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"></path>
56
+ </svg>
57
+ );
58
+ }
59
+
60
+ export const ToastTypeIcons: Record<Exclude<ToastType, 'custom' | 'default'>, () => ReactNode> = {
61
+ success: SuccessSvg,
62
+ error: ErrorSvg,
63
+ loading: () => <div className={`${STYLE_NAMESPACE}__loading`} />,
64
+ warn: WarnSvg,
65
+ };
66
+
67
+ interface DefaultToastProps {
68
+ status: ToastType;
69
+ }
70
+
71
+ export function DefaultToast({ status, children }: DefaultToastProps & PropsWithChildren) {
72
+ const Icon = status === 'custom' || status === 'default' ? null : ToastTypeIcons[status];
73
+
74
+ return (
75
+ <div
76
+ style={{
77
+ boxSizing: 'border-box',
78
+ backgroundColor: 'white',
79
+ padding: '12px 14px 12px 12px',
80
+ display: 'flex',
81
+ alignItems: 'center',
82
+ gap: 5,
83
+ borderRadius: 8,
84
+ boxShadow: '2px 4px 10px rgba(0, 0, 0, 0.1)',
85
+ }}
86
+ >
87
+ {Icon && (
88
+ <span style={{ minWidth: 20, maxWidth: 20 }}>
89
+ <Icon />
90
+ </span>
91
+ )}
92
+
93
+ {children}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,84 @@
1
+ import React, { useEffect } from 'react';
2
+ import { Condition, If, Else } from './condition';
3
+ import { getAnimation } from '../utils/get-animation';
4
+ import { toast } from '../core/toast';
5
+ import { DISAPPEAR_TIMEOUT, MAX_TIMEOUT } from '../constants';
6
+ import { DefaultToast, ToastTypeIcons } from './toast-default';
7
+ import type { NonHeadlessToastState as ToastState } from '../types';
8
+ import '../styles/index.scss';
9
+
10
+ interface ToasterProps {
11
+ toastProps: ToastState;
12
+ style: React.CSSProperties;
13
+ }
14
+
15
+ export function Toast({ toastProps, ...rest }: ToasterProps) {
16
+ const animationClassName = getAnimation({
17
+ isVisible: toastProps.isVisible,
18
+ position: toastProps.position!,
19
+ });
20
+
21
+ const content =
22
+ typeof toastProps.data === 'function'
23
+ ? toastProps.data({
24
+ close: () => toast.disappear(toastProps.toastId, 0),
25
+ immediatelyClose: () => {
26
+ toast.disappear(toastProps.toastId, 0);
27
+ toast.remove(toastProps.toastId, 0);
28
+ },
29
+ icons: {
30
+ success: <ToastTypeIcons.success />,
31
+ error: <ToastTypeIcons.error />,
32
+ warn: <ToastTypeIcons.warn />,
33
+ loading: <ToastTypeIcons.loading />,
34
+ },
35
+ isVisible: toastProps.isVisible,
36
+ })
37
+ : toastProps.data;
38
+
39
+ const onMouseEnter = () => {
40
+ if (toastProps.pauseOnHover) {
41
+ toast.pause(toastProps.toastId);
42
+ }
43
+ };
44
+
45
+ const onMouseLeave = () => {
46
+ if (toastProps.pauseOnHover) {
47
+ toast.resume(toastProps.toastId);
48
+ }
49
+ };
50
+
51
+ /** @description disappear after mount */
52
+ useEffect(() => {
53
+ if (!toast.isActive(toastProps.toastId)) {
54
+ toast.setActive(toastProps.toastId);
55
+ toast.disappear(toastProps.toastId, toastProps.timeOut);
56
+ }
57
+ }, [toastProps.toastId]);
58
+
59
+ /** @description promise toast */
60
+ useEffect(() => {
61
+ if (toastProps.updated !== undefined) {
62
+ const newTimeOut = toastProps.timeOut >= MAX_TIMEOUT ? DISAPPEAR_TIMEOUT : toastProps.timeOut;
63
+ toast.disappear(toastProps.toastId, newTimeOut);
64
+ }
65
+ }, [toastProps.updated]);
66
+
67
+ return (
68
+ <div
69
+ role="alert"
70
+ data-testid={`container-${toastProps.containerId}`}
71
+ className={toastProps.toastType === 'custom' ? '' : animationClassName}
72
+ onMouseEnter={onMouseEnter}
73
+ onMouseLeave={onMouseLeave}
74
+ {...rest}
75
+ >
76
+ <Condition condition={toastProps.toastType !== 'custom'}>
77
+ <If>
78
+ <DefaultToast status={toastProps.toastType}>{content}</DefaultToast>
79
+ </If>
80
+ <Else>{content}</Else>
81
+ </Condition>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,8 @@
1
+ export const DISAPPEAR_TIMEOUT = 3_000;
2
+
3
+ export const MAX_TIMEOUT = ~(1 << 31);
4
+
5
+ /** @description time before the toast component is invisible and deleted from the list */
6
+ export const REMOVE_TIMEOUT = 200;
7
+
8
+ export const STYLE_NAMESPACE = 'react-strawberry-toast';
@@ -0,0 +1,49 @@
1
+ import { generateId } from '../utils/generate-id';
2
+ import { ToastStore } from '../core/store';
3
+ import { REMOVE_TIMEOUT, MAX_TIMEOUT, DISAPPEAR_TIMEOUT } from '../constants';
4
+ import { toastHandlers } from './toast-handler';
5
+ import type { BaseOptions, ToastState } from '../types';
6
+
7
+ export const toastStore = new ToastStore<ToastState>();
8
+
9
+ const idGenerator = generateId();
10
+
11
+ const createToast =
12
+ () =>
13
+ (data: ToastState['data'], options: BaseOptions = {}): ToastState['toastId'] => {
14
+ const { timeOut = DISAPPEAR_TIMEOUT, removeTimeOut = REMOVE_TIMEOUT, toastId: optionToastId } = options;
15
+
16
+ const toastId = optionToastId || idGenerator();
17
+
18
+ if (toast.isActive(toastId)) {
19
+ throw new Error('A duplicate custom ID is not available.');
20
+ }
21
+
22
+ const createdAt = new Date().getTime();
23
+
24
+ const value: ToastState = {
25
+ ...options,
26
+ timeOut: timeOut > MAX_TIMEOUT ? MAX_TIMEOUT : timeOut,
27
+ toastId,
28
+ data,
29
+ createdAt,
30
+ removeTimeOut,
31
+ isVisible: true,
32
+ };
33
+
34
+ toastStore.state.push(value);
35
+ toastStore.setState([...toastStore.state]);
36
+
37
+ return toastId;
38
+ };
39
+
40
+ export const toast = (data: ToastState['data'], options: BaseOptions = {}) => createToast()(data, options);
41
+
42
+ const handlers = toastHandlers(toastStore);
43
+ toast.setActive = handlers.setActive;
44
+ toast.isActive = handlers.isActive;
45
+ toast.disappear = handlers.disappear;
46
+ toast.resume = handlers.resume;
47
+ toast.pause = handlers.pause;
48
+ toast.replace = handlers.replace;
49
+ toast.remove = handlers.remove;
@@ -0,0 +1,34 @@
1
+ import { ToastState } from "../types";
2
+
3
+ type Listener = () => void;
4
+
5
+ export class ToastStore<T = ToastState> {
6
+ state: Array<T> = [];
7
+
8
+ listeners = new Set<Listener>();
9
+
10
+ /** @description list containing toast activated since mount */
11
+ activatedToasts = new Set<ToastState['toastId']>();
12
+
13
+ /** @description list with a timer for toast */
14
+ toastTimers = new Map<ToastState['toastId'], number>();
15
+
16
+ constructor() {}
17
+
18
+ subscribe(listener: Listener): Listener {
19
+ this.listeners.add(listener);
20
+ return () => {
21
+ this.listeners.delete(listener);
22
+ };
23
+ }
24
+
25
+ /** @description must put a new memory value in the nextState. */
26
+ setState(nextState: Array<T> | ((state: Array<T>) => Array<T>)): void {
27
+ this.state = typeof nextState === 'function' ? nextState(this.state) : nextState;
28
+ this.listeners.forEach((listener) => listener());
29
+ }
30
+
31
+ getSnapShot(): Array<T> {
32
+ return this.state;
33
+ }
34
+ }
@@ -0,0 +1,108 @@
1
+ import { ToastStore } from './store';
2
+ import { REMOVE_TIMEOUT, MAX_TIMEOUT, DISAPPEAR_TIMEOUT } from '../constants';
3
+ import type { ToastState, Options, NonHeadlessToastState } from '../types';
4
+
5
+ export const toastHandlers = <T = ToastState>(
6
+ toastStore: ToastStore<T extends ToastState ? ToastState : NonHeadlessToastState>
7
+ ) => {
8
+ const deleteTimer = (toastId: ToastState['toastId']) => {
9
+ const timerId = toastStore.toastTimers.get(toastId);
10
+ clearTimeout(timerId);
11
+
12
+ toastStore.toastTimers.delete(toastId);
13
+ };
14
+
15
+ const setActive = (toastId: ToastState['toastId']): void => {
16
+ toastStore.activatedToasts.add(toastId);
17
+ }
18
+
19
+ const isActive = (toastId: ToastState['toastId']): boolean => toastStore.activatedToasts.has(toastId);
20
+
21
+ const remove = (toastId: ToastState['toastId'], timeOut = REMOVE_TIMEOUT) => {
22
+ if (!isActive(toastId)) {
23
+ return;
24
+ }
25
+
26
+ toastStore.activatedToasts.delete(toastId);
27
+
28
+ setTimeout(() => {
29
+ toastStore.state = toastStore.state.filter((toast) => toast.toastId !== toastId);
30
+ toastStore.setState([...toastStore.state]);
31
+ }, timeOut);
32
+
33
+ deleteTimer(toastId);
34
+ };
35
+
36
+ const replace = (
37
+ toastId: ToastState['toastId'],
38
+ data: ToastState['data'],
39
+ options?: Options & { toastType: NonHeadlessToastState['toastType'] }
40
+ ) => {
41
+ toastStore.state = toastStore.state.map((toast) => {
42
+ if (toast.toastId === toastId) {
43
+ return {
44
+ ...toast,
45
+ ...options,
46
+ updated: true,
47
+ data,
48
+ };
49
+ }
50
+ return toast;
51
+ });
52
+
53
+ toastStore.setState([...toastStore.state]);
54
+ };
55
+
56
+ const pause = (toastId: ToastState['toastId']): void => {
57
+ const pausedAt = new Date().getTime();
58
+
59
+ toastStore.state = toastStore.state.map((toast) => {
60
+ if (toast.toastId === toastId) {
61
+ return {
62
+ ...toast,
63
+ pausedAt,
64
+ };
65
+ }
66
+ return toast;
67
+ });
68
+
69
+ deleteTimer(toastId);
70
+ toastStore.setState([...toastStore.state]);
71
+ };
72
+
73
+ const disappear = (toastId: ToastState['toastId'], timeOut: number): void => {
74
+ const timer = setTimeout(
75
+ () => {
76
+ toastStore.state = toastStore.state.map((toast) => {
77
+ if (toast.toastId === toastId) {
78
+ return {
79
+ ...toast,
80
+ isVisible: false,
81
+ };
82
+ }
83
+ return toast;
84
+ });
85
+
86
+ toastStore.setState([...toastStore.state]);
87
+
88
+ const removeTimeOut = toastStore.state.find((toast) => toast.toastId === toastId)?.removeTimeOut;
89
+ remove(toastId, removeTimeOut);
90
+ },
91
+ timeOut > MAX_TIMEOUT ? MAX_TIMEOUT : timeOut
92
+ );
93
+
94
+ toastStore.toastTimers.set(toastId, timer);
95
+ };
96
+
97
+ const resume = (toastId: ToastState['toastId']): void => {
98
+ if (toastStore.toastTimers.has(toastId)) return;
99
+
100
+ const target = toastStore.state.find((toast) => toast.toastId === toastId);
101
+ if (!target) return;
102
+
103
+ const leftTimeout = target.createdAt + (target.timeOut || DISAPPEAR_TIMEOUT) - (target.pausedAt || 0);
104
+ disappear(toastId, leftTimeout);
105
+ };
106
+
107
+ return { setActive, isActive, disappear, resume, pause, replace, remove };
108
+ };
@@ -0,0 +1,98 @@
1
+ import { generateId } from '../utils/generate-id';
2
+ import { ToastStore } from '../core/store';
3
+ import { REMOVE_TIMEOUT, MAX_TIMEOUT, DISAPPEAR_TIMEOUT } from '../constants';
4
+ import { toastHandlers } from './toast-handler';
5
+ import type { ReactNode } from 'react';
6
+ import type { NonHeadlessToastState, ToastState, Options, ToastType, ToastDataWithCallback } from '../types';
7
+
8
+ export const toastStore = new ToastStore<NonHeadlessToastState>();
9
+
10
+ const idGenerator = generateId();
11
+
12
+ const createToast =
13
+ <T = ToastState['data']>(toastType: ToastType = 'default') =>
14
+ (
15
+ data: T extends ToastState['data'] ? ToastState['data'] : ToastDataWithCallback,
16
+ options: Options = {}
17
+ ): ToastState['toastId'] => {
18
+ const {
19
+ timeOut = DISAPPEAR_TIMEOUT,
20
+ removeTimeOut = REMOVE_TIMEOUT,
21
+ pauseOnHover = true,
22
+ toastId: optionToastId,
23
+ } = options;
24
+
25
+ const toastId = optionToastId || idGenerator();
26
+
27
+ if (toast.isActive(toastId)) {
28
+ throw new Error('A duplicate custom ID is not available.');
29
+ }
30
+
31
+ const createdAt = new Date().getTime();
32
+
33
+ const value: NonHeadlessToastState = {
34
+ ...options,
35
+ timeOut: timeOut > MAX_TIMEOUT ? MAX_TIMEOUT : timeOut,
36
+ toastId,
37
+ data,
38
+ createdAt,
39
+ toastType,
40
+ pauseOnHover,
41
+ removeTimeOut,
42
+ isVisible: true,
43
+ };
44
+
45
+ toastStore.state.push(value);
46
+ toastStore.setState([...toastStore.state]);
47
+
48
+ return toastId;
49
+ };
50
+
51
+ export const toast = (data: ToastState['data'] | ToastDataWithCallback, options: Options = {}) =>
52
+ createToast<ToastState['data'] | ToastDataWithCallback>()(data, options);
53
+
54
+ const handlers = toastHandlers<NonHeadlessToastState>(toastStore);
55
+ toast.setActive = handlers.setActive;
56
+ toast.isActive = handlers.isActive;
57
+ toast.disappear = handlers.disappear;
58
+ toast.resume = handlers.resume;
59
+ toast.pause = handlers.pause;
60
+ toast.replace = handlers.replace;
61
+ toast.remove = handlers.remove;
62
+
63
+ toast.success = createToast('success');
64
+ toast.error = createToast('error');
65
+ toast.warn = createToast('warn');
66
+ toast.loading = createToast('loading');
67
+ toast.custom = createToast<ToastState['data'] | ToastDataWithCallback>('custom');
68
+
69
+ toast.promise = (
70
+ promise: Promise<any>,
71
+ promiseOption: {
72
+ loading: ReactNode;
73
+ success: ReactNode;
74
+ error: ReactNode;
75
+ },
76
+ options: Options = {}
77
+ ) => {
78
+ const { loading, success, error } = promiseOption;
79
+
80
+ const toastId = toast.loading(loading, {
81
+ ...options,
82
+ timeOut: MAX_TIMEOUT,
83
+ });
84
+
85
+ promise
86
+ .then(() => {
87
+ toast.replace(toastId, success, {
88
+ ...options,
89
+ toastType: 'success',
90
+ });
91
+ })
92
+ .catch(() => {
93
+ toast.replace(toastId, error, {
94
+ ...options,
95
+ toastType: 'error',
96
+ });
97
+ });
98
+ };
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ namespace Vi {
3
+ interface Assertion extends jest.Matchers<void> {}
4
+ interface AsymmetricMatchers extends jest.Matchers<void> {}
5
+ }
6
+ }
@@ -0,0 +1,3 @@
1
+ export * from './hooks/use-toasts';
2
+ export { toast } from './core/headless-toast';
3
+ export type { ToastState, BaseOptions } from './types';
@@ -0,0 +1,11 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { toastStore } from '../core/headless-toast';
3
+ import type { ToastState } from '../types';
4
+
5
+ export const useToasts = (): Array<ToastState> => {
6
+ return useSyncExternalStore(
7
+ toastStore.subscribe.bind(toastStore),
8
+ toastStore.getSnapShot.bind(toastStore),
9
+ toastStore.getSnapShot.bind(toastStore)
10
+ );
11
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './core/toast';
2
+ export * from './components/toast-container';
3
+ export * from './types';