Skip to content

Commit 585c57d

Browse files
committed
feat(Toast): add Toast component
1 parent 7ef381f commit 585c57d

File tree

11 files changed

+1069
-2
lines changed

11 files changed

+1069
-2
lines changed

packages/react-ui/src/components/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,7 @@ export * from "./separator/separator";
4040
export * from "./spinner/spinner";
4141
export * from "./switch/switch";
4242
export * from "./tabs/tabs";
43+
export * from "./toast/toast";
44+
export * from "./toast/use-toast";
4345
export * from "./tooltip/tooltip";
4446
export * from "./visually-hidden/visually-hidden";
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import type { PropsWithChildren } from "react";
2+
import { ToastProvider, type ToastProviderProps } from "../toast/toast-provider";
23
import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode-provider";
34

45
export type ResolidProviderProps = {
56
colorMode?: ColorModeProviderProps;
7+
toastOptions?: ToastProviderProps;
68
};
79

8-
export const ResolidProvider = ({ children, colorMode }: PropsWithChildren<ResolidProviderProps>) => {
9-
return <ColorModeProvider {...colorMode}>{children}</ColorModeProvider>;
10+
export const ResolidProvider = ({ children, colorMode, toastOptions }: PropsWithChildren<ResolidProviderProps>) => {
11+
return (
12+
<ColorModeProvider {...colorMode}>
13+
<ToastProvider {...toastOptions}>{children}</ToastProvider>
14+
</ColorModeProvider>
15+
);
1016
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Alignment } from "@floating-ui/react";
2+
import type { ReactElement } from "react";
3+
import { createSafeContext } from "../../primitives";
4+
5+
export type ToastId = string | number;
6+
7+
export type ToastPlacement = "top" | "bottom" | `top-${Alignment}` | `bottom-${Alignment}`;
8+
9+
export type ToastOptions = {
10+
id?: ToastId;
11+
12+
/**
13+
* 自动关闭延时, 覆盖 `ResolidProvider` 提供的值
14+
*/
15+
duration?: number | null;
16+
17+
/**
18+
* 显示位置
19+
*/
20+
placement?: ToastPlacement;
21+
};
22+
23+
type ToastBaseProps = Required<ToastOptions> & {
24+
update: boolean;
25+
dismiss: boolean;
26+
};
27+
28+
export type ToastComponentContextValue = ToastBaseProps & {
29+
remove: () => void;
30+
};
31+
32+
export const [ToastComponentContext, useToastComponent] = createSafeContext<ToastComponentContextValue>({
33+
name: "ToastComponentContext",
34+
});
35+
36+
export type ToastConfig = ToastBaseProps & {
37+
component: () => ReactElement;
38+
};
39+
40+
export type ToastPromiseState = "pending" | "success" | "failure";
41+
42+
export type ToastPromiseComponentProps<T, E> = {
43+
state: ToastPromiseState;
44+
data?: T;
45+
error?: E;
46+
};
47+
48+
export type ToastContextValue = {
49+
show: (component: ReactElement, options?: ToastOptions) => ToastId;
50+
update: (id: ToastId, component: ReactElement) => void;
51+
dismiss: (id: ToastId) => void;
52+
showPromise: <T = unknown, E extends Error = Error>(
53+
promise: Promise<T> | (() => Promise<T>),
54+
component: (props: ToastPromiseComponentProps<T, E>) => ReactElement,
55+
options?: ToastOptions,
56+
) => ToastId;
57+
clearAll: (...args: ToastPlacement[]) => void;
58+
};
59+
60+
export const [ToastContext, useToast] = createSafeContext<ToastContextValue>({
61+
name: "ToastContext",
62+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { runIf } from "@resolid/utils";
2+
import { type PropsWithChildren, type ReactElement, useReducer } from "react";
3+
import { PortalLite } from "../portal/portal-lite";
4+
import {
5+
type ToastConfig,
6+
ToastContext,
7+
type ToastContextValue,
8+
type ToastId,
9+
type ToastPlacement,
10+
} from "./toast-context";
11+
import { ToastRegion, type ToastRegionBaseProps } from "./toast-region";
12+
13+
export type ToastProviderProps = {
14+
/**
15+
* 自动关闭延时, 设置为 `null` 时不自动关闭
16+
* @default 5000
17+
*/
18+
duration?: number | null;
19+
} & Partial<ToastRegionBaseProps>;
20+
21+
type ToastState = {
22+
[K in ToastPlacement]: ToastConfig[];
23+
};
24+
25+
type ToastAction =
26+
| { type: "ADD"; payload: { toast: ToastConfig } }
27+
| { type: "UPDATE"; payload: { id: ToastId; component: ReactElement; duration?: number | null } }
28+
| { type: "DISMISS"; payload: { id: ToastId } }
29+
| { type: "CLEAR"; payload: { placements?: ToastPlacement[] } }
30+
| { type: "REMOVE"; payload: { id: ToastId } };
31+
32+
const reducer = (state: ToastState, action: ToastAction) => {
33+
switch (action.type) {
34+
case "ADD": {
35+
const { toast } = action.payload;
36+
const placement = toast.placement;
37+
38+
return {
39+
...state,
40+
[placement]: placement.slice(0, 3) == "top" ? [toast, ...state[placement]] : [...state[placement], toast],
41+
};
42+
}
43+
case "UPDATE": {
44+
const { id, component, duration } = action.payload;
45+
46+
const [placement, index] = getPlacementAndIndexById(state, id);
47+
48+
if (placement == undefined || index == undefined) {
49+
return state;
50+
}
51+
52+
return {
53+
...state,
54+
[placement]: [
55+
...state[placement].slice(0, index),
56+
{
57+
id,
58+
duration: duration !== undefined ? duration : state[placement][index].duration,
59+
component: () => component,
60+
update: true,
61+
dismiss: false,
62+
},
63+
...state[placement].slice(index + 1),
64+
],
65+
};
66+
}
67+
case "DISMISS": {
68+
const { id } = action.payload;
69+
70+
const [placement, index] = getPlacementAndIndexById(state, id);
71+
72+
if (placement == undefined || index == undefined) {
73+
return state;
74+
}
75+
76+
return {
77+
...state,
78+
[placement]: [
79+
...state[placement].slice(0, index),
80+
{ ...state[placement][index], dismiss: true },
81+
...state[placement].slice(index + 1),
82+
],
83+
};
84+
}
85+
case "CLEAR": {
86+
const placements =
87+
!action.payload.placements || action.payload.placements.length == 0
88+
? (["bottom", "bottom-start", "bottom-end", "top", "top-start", "top-end"] as ToastPlacement[])
89+
: action.payload.placements;
90+
91+
const result = { ...state };
92+
93+
for (const placement of placements) {
94+
result[placement] = state[placement].map((toast) => ({ ...toast, dismiss: true }));
95+
}
96+
97+
return result;
98+
}
99+
case "REMOVE": {
100+
const { id } = action.payload;
101+
102+
const [placement, index] = getPlacementAndIndexById(state, id);
103+
104+
if (placement == undefined || index == undefined) {
105+
return state;
106+
}
107+
108+
return {
109+
...state,
110+
[placement]: [...state[placement].slice(0, index), ...state[placement].slice(index + 1)],
111+
};
112+
}
113+
default:
114+
return state;
115+
}
116+
};
117+
118+
export const ToastProvider = ({
119+
placement = "bottom-end",
120+
duration = 5000,
121+
spacing = "0.75rem",
122+
visibleToasts = 5,
123+
children,
124+
}: PropsWithChildren<ToastProviderProps>) => {
125+
const [state, dispatch] = useReducer(reducer, {
126+
"top-start": [],
127+
top: [],
128+
"top-end": [],
129+
"bottom-start": [],
130+
bottom: [],
131+
"bottom-end": [],
132+
});
133+
134+
const context: ToastContextValue = {
135+
show: (component, options) => {
136+
const toastId = options?.id ?? `t-${Math.random().toString(36).slice(2, 9)}`;
137+
const toastDuration = options?.duration === undefined ? duration : options.duration;
138+
const toastPlacement = options?.placement ?? placement;
139+
140+
dispatch({
141+
type: "ADD",
142+
payload: {
143+
toast: {
144+
id: toastId,
145+
placement: toastPlacement,
146+
duration: toastDuration,
147+
component: () => component,
148+
update: false,
149+
dismiss: false,
150+
},
151+
},
152+
});
153+
154+
return toastId;
155+
},
156+
update: (id, component) => {
157+
dispatch({ type: "UPDATE", payload: { id, component } });
158+
},
159+
dismiss: (id) => {
160+
dispatch({ type: "DISMISS", payload: { id } });
161+
},
162+
showPromise: (promise, component, options) => {
163+
const originalDuration = options?.duration === undefined ? duration : options.duration;
164+
165+
const toastId = context.show(component({ state: "pending" }), { ...options, duration: null });
166+
167+
runIf(promise)
168+
.then((data) => {
169+
dispatch({
170+
type: "UPDATE",
171+
payload: { id: toastId, component: component({ state: "success", data }), duration: originalDuration },
172+
});
173+
})
174+
.catch((error) => {
175+
dispatch({
176+
type: "UPDATE",
177+
payload: { id: toastId, component: component({ state: "failure", error }), duration: originalDuration },
178+
});
179+
});
180+
181+
return toastId;
182+
},
183+
clearAll: (...args) => {
184+
dispatch({ type: "CLEAR", payload: { placements: args } });
185+
},
186+
};
187+
188+
const remove = (id: ToastId) => {
189+
dispatch({ type: "REMOVE", payload: { id } });
190+
};
191+
192+
return (
193+
<ToastContext value={context}>
194+
{children}
195+
<PortalLite>
196+
{Object.entries(state).map(([placement, toasts]) => {
197+
if (toasts.length == 0) {
198+
return null;
199+
}
200+
201+
return (
202+
<ToastRegion
203+
key={placement}
204+
placement={placement as ToastPlacement}
205+
spacing={spacing}
206+
visibleToasts={visibleToasts}
207+
toasts={toasts}
208+
remove={remove}
209+
/>
210+
);
211+
})}
212+
</PortalLite>
213+
</ToastContext>
214+
);
215+
};
216+
217+
const getPlacementAndIndexById = (state: ToastState, id: ToastId): [ToastPlacement | undefined, number | undefined] => {
218+
for (const [placement, toasts] of Object.entries(state)) {
219+
const index = toasts.findIndex((toast) => toast.id == id);
220+
221+
if (index > -1) {
222+
return [placement as ToastPlacement, index];
223+
}
224+
}
225+
226+
return [undefined, undefined];
227+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { CSSProperties } from "react";
2+
import { tx } from "../../utils";
3+
import { ToastComponentContext, type ToastConfig, type ToastId, type ToastPlacement } from "./toast-context";
4+
5+
export type ToastRegionBaseProps = {
6+
/**
7+
* 默认放置位置
8+
* @default "bottom-end"
9+
*/
10+
placement: ToastPlacement;
11+
12+
/**
13+
* Toast 之间的间隔
14+
* @default "0.75rem"
15+
*/
16+
spacing: string;
17+
18+
/**
19+
* 同时可见的 Toast 数量
20+
* @default 5
21+
*/
22+
visibleToasts: number;
23+
};
24+
25+
export type ToastRegionProps = ToastRegionBaseProps & {
26+
toasts: ToastConfig[];
27+
remove: (id: ToastId) => void;
28+
};
29+
30+
export const ToastRegion = ({ placement, spacing, visibleToasts, toasts, remove }: ToastRegionProps) => {
31+
return (
32+
<div
33+
role={"region"}
34+
aria-live={"polite"}
35+
style={{ "--sv": spacing } as CSSProperties}
36+
className={tx("z-60 m-(--sv) gap-(--sv) pointer-events-none fixed flex flex-col", getToastListStyles(placement))}
37+
>
38+
{toasts.slice(0, visibleToasts).map((toast) => {
39+
const ToastComponent = toast.component;
40+
41+
return (
42+
<ToastComponentContext
43+
key={toast.id}
44+
value={{
45+
id: toast.id,
46+
placement: placement,
47+
duration: toast.duration,
48+
dismiss: toast.dismiss,
49+
update: toast.update,
50+
remove: () => remove(toast.id),
51+
}}
52+
>
53+
<ToastComponent />
54+
</ToastComponentContext>
55+
);
56+
})}
57+
</div>
58+
);
59+
};
60+
61+
const getToastListStyles = (placement: ToastPlacement) => {
62+
const styles = [];
63+
64+
if (placement.includes("top")) {
65+
styles.push("top-0");
66+
}
67+
68+
if (placement.includes("bottom")) {
69+
styles.push("bottom-0");
70+
}
71+
72+
if (!placement.includes("start")) {
73+
styles.push("right-0");
74+
}
75+
76+
if (!placement.includes("end")) {
77+
styles.push("left-0");
78+
}
79+
80+
return styles.join(" ");
81+
};

0 commit comments

Comments
 (0)