podo-ui 0.2.0 → 0.2.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.
package/global.scss
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
@use '../../mixin' as *;
|
|
2
|
+
|
|
3
|
+
.toastPortal {
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset: 0;
|
|
6
|
+
pointer-events: none;
|
|
7
|
+
z-index: 9999;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.toastContainer {
|
|
11
|
+
position: fixed;
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
gap: s(3);
|
|
15
|
+
padding: s(5);
|
|
16
|
+
pointer-events: auto;
|
|
17
|
+
|
|
18
|
+
// Top positions
|
|
19
|
+
&.top-left {
|
|
20
|
+
top: 0;
|
|
21
|
+
left: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&.top-center {
|
|
25
|
+
top: 0;
|
|
26
|
+
left: 50%;
|
|
27
|
+
transform: translateX(-50%);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&.top-right {
|
|
31
|
+
top: 0;
|
|
32
|
+
right: 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Center positions
|
|
36
|
+
&.center-left {
|
|
37
|
+
top: 50%;
|
|
38
|
+
left: 0;
|
|
39
|
+
transform: translateY(-50%);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&.center {
|
|
43
|
+
top: 50%;
|
|
44
|
+
left: 50%;
|
|
45
|
+
transform: translate(-50%, -50%);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&.center-right {
|
|
49
|
+
top: 50%;
|
|
50
|
+
right: 0;
|
|
51
|
+
transform: translateY(-50%);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Bottom positions
|
|
55
|
+
&.bottom-left {
|
|
56
|
+
bottom: 0;
|
|
57
|
+
left: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
&.bottom-center {
|
|
61
|
+
bottom: 0;
|
|
62
|
+
left: 50%;
|
|
63
|
+
transform: translateX(-50%);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&.bottom-right {
|
|
67
|
+
bottom: 0;
|
|
68
|
+
right: 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useCallback } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import Toast, { ToastProps, ToastPosition, ToastVariant } from './toast';
|
|
6
|
+
import styles from './toast-container.module.scss';
|
|
7
|
+
|
|
8
|
+
interface ToastData extends Omit<ToastProps, 'id' | 'onClose'> {
|
|
9
|
+
id: string;
|
|
10
|
+
position: ToastPosition;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ToastOptions {
|
|
14
|
+
header?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
variant?: ToastVariant;
|
|
17
|
+
type?: 'type01' | 'type02';
|
|
18
|
+
long?: boolean;
|
|
19
|
+
duration?: number;
|
|
20
|
+
width?: string | number;
|
|
21
|
+
position?: ToastPosition;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToastContextType {
|
|
25
|
+
showToast: (options: ToastOptions) => string;
|
|
26
|
+
hideToast: (id: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
|
30
|
+
|
|
31
|
+
export const useToast = () => {
|
|
32
|
+
const context = useContext(ToastContext);
|
|
33
|
+
if (!context) {
|
|
34
|
+
throw new Error('useToast must be used within ToastProvider');
|
|
35
|
+
}
|
|
36
|
+
return context;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
40
|
+
const [toasts, setToasts] = useState<ToastData[]>([]);
|
|
41
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
setIsMounted(true);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const showToast = useCallback((options: ToastOptions): string => {
|
|
48
|
+
const id = `toast-${Date.now()}-${Math.random()}`;
|
|
49
|
+
const position = options.position || 'top-right';
|
|
50
|
+
|
|
51
|
+
setToasts((prev) => [
|
|
52
|
+
...prev,
|
|
53
|
+
{
|
|
54
|
+
id,
|
|
55
|
+
position,
|
|
56
|
+
header: options.header,
|
|
57
|
+
message: options.message,
|
|
58
|
+
variant: options.variant,
|
|
59
|
+
type: options.type,
|
|
60
|
+
long: options.long,
|
|
61
|
+
duration: options.duration,
|
|
62
|
+
width: options.width,
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
return id;
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const hideToast = useCallback((id: string) => {
|
|
70
|
+
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const getToastsByPosition = (position: ToastPosition) => {
|
|
74
|
+
return toasts.filter((toast) => toast.position === position);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const positions: ToastPosition[] = [
|
|
78
|
+
'top-left',
|
|
79
|
+
'top-center',
|
|
80
|
+
'top-right',
|
|
81
|
+
'center-left',
|
|
82
|
+
'center',
|
|
83
|
+
'center-right',
|
|
84
|
+
'bottom-left',
|
|
85
|
+
'bottom-center',
|
|
86
|
+
'bottom-right',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<ToastContext.Provider value={{ showToast, hideToast }}>
|
|
91
|
+
{children}
|
|
92
|
+
{isMounted &&
|
|
93
|
+
createPortal(
|
|
94
|
+
<div className={styles.toastPortal}>
|
|
95
|
+
{positions.map((position) => {
|
|
96
|
+
const positionToasts = getToastsByPosition(position);
|
|
97
|
+
if (positionToasts.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div key={position} className={`${styles.toastContainer} ${styles[position]}`}>
|
|
101
|
+
{positionToasts.map((toast) => (
|
|
102
|
+
<Toast
|
|
103
|
+
key={toast.id}
|
|
104
|
+
id={toast.id}
|
|
105
|
+
header={toast.header}
|
|
106
|
+
message={toast.message}
|
|
107
|
+
variant={toast.variant}
|
|
108
|
+
type={toast.type}
|
|
109
|
+
long={toast.long}
|
|
110
|
+
duration={toast.duration}
|
|
111
|
+
width={toast.width}
|
|
112
|
+
onClose={hideToast}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</div>,
|
|
119
|
+
document.body
|
|
120
|
+
)}
|
|
121
|
+
</ToastContext.Provider>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import styles from './toast.module.scss';
|
|
5
|
+
|
|
6
|
+
export type ToastPosition =
|
|
7
|
+
| 'top-left'
|
|
8
|
+
| 'top-center'
|
|
9
|
+
| 'top-right'
|
|
10
|
+
| 'center-left'
|
|
11
|
+
| 'center'
|
|
12
|
+
| 'center-right'
|
|
13
|
+
| 'bottom-left'
|
|
14
|
+
| 'bottom-center'
|
|
15
|
+
| 'bottom-right';
|
|
16
|
+
|
|
17
|
+
export type ToastVariant = 'default' | 'primary' | 'info' | 'success' | 'warning' | 'danger';
|
|
18
|
+
|
|
19
|
+
export interface ToastProps {
|
|
20
|
+
id: string;
|
|
21
|
+
header?: string;
|
|
22
|
+
message: string;
|
|
23
|
+
variant?: ToastVariant;
|
|
24
|
+
type?: 'type01' | 'type02';
|
|
25
|
+
long?: boolean;
|
|
26
|
+
duration?: number;
|
|
27
|
+
width?: string | number;
|
|
28
|
+
onClose: (id: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const Toast: React.FC<ToastProps> = ({
|
|
32
|
+
id,
|
|
33
|
+
header,
|
|
34
|
+
message,
|
|
35
|
+
variant = 'default',
|
|
36
|
+
type = 'type01',
|
|
37
|
+
long = false,
|
|
38
|
+
duration = 3000,
|
|
39
|
+
width,
|
|
40
|
+
onClose,
|
|
41
|
+
}) => {
|
|
42
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
43
|
+
const [isClosing, setIsClosing] = useState(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
// Fade in
|
|
47
|
+
requestAnimationFrame(() => {
|
|
48
|
+
setIsVisible(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Auto close
|
|
52
|
+
if (duration > 0) {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
handleClose();
|
|
55
|
+
}, duration);
|
|
56
|
+
|
|
57
|
+
return () => clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}, [duration]);
|
|
60
|
+
|
|
61
|
+
const handleClose = () => {
|
|
62
|
+
setIsClosing(true);
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
onClose(id);
|
|
65
|
+
}, 200); // 0.2s fade out
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const toastClasses = [
|
|
69
|
+
'toast',
|
|
70
|
+
variant,
|
|
71
|
+
type === 'type02' ? 'toast-border' : '',
|
|
72
|
+
long ? 'toast-long' : '',
|
|
73
|
+
styles.toastAnimation,
|
|
74
|
+
isVisible && !isClosing ? styles.fadeIn : '',
|
|
75
|
+
isClosing ? styles.fadeOut : '',
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(' ');
|
|
79
|
+
|
|
80
|
+
const toastStyle: React.CSSProperties = {
|
|
81
|
+
width: width ? (typeof width === 'number' ? `${width}px` : width) : 'auto',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const getIcon = () => {
|
|
85
|
+
switch (variant) {
|
|
86
|
+
case 'success':
|
|
87
|
+
return 'icon-check';
|
|
88
|
+
case 'warning':
|
|
89
|
+
return 'icon-warning';
|
|
90
|
+
case 'danger':
|
|
91
|
+
return 'icon-danger';
|
|
92
|
+
case 'primary':
|
|
93
|
+
case 'info':
|
|
94
|
+
case 'default':
|
|
95
|
+
default:
|
|
96
|
+
return 'icon-info';
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className={toastClasses} style={toastStyle}>
|
|
102
|
+
<div className="toast-icon">
|
|
103
|
+
<i className={getIcon()}></i>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="toast-content">
|
|
106
|
+
{header && !long && <div className="toast-header">{header}</div>}
|
|
107
|
+
<div className="toast-body">{message}</div>
|
|
108
|
+
</div>
|
|
109
|
+
<button className="toast-close" onClick={handleClose}>
|
|
110
|
+
<i className="icon-close"></i>
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default Toast;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
@use '../color/function' as *;
|
|
2
|
+
@use '../layout/spacing' as *;
|
|
3
|
+
@use '../layout/radius' as *;
|
|
4
|
+
|
|
5
|
+
.toast {
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: flex-start;
|
|
8
|
+
gap: s(3);
|
|
9
|
+
padding: s(4) s(5);
|
|
10
|
+
border-radius: r(1);
|
|
11
|
+
background-color: color('bg-elevation-1');
|
|
12
|
+
border: none;
|
|
13
|
+
border-top: 4px solid color('border');
|
|
14
|
+
position: relative;
|
|
15
|
+
min-width: 320px;
|
|
16
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
17
|
+
|
|
18
|
+
// Icon area
|
|
19
|
+
.toast-icon {
|
|
20
|
+
flex-shrink: 0;
|
|
21
|
+
width: 24px;
|
|
22
|
+
height: 24px;
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: center;
|
|
26
|
+
|
|
27
|
+
i {
|
|
28
|
+
font-size: 20px;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Content area
|
|
33
|
+
.toast-content {
|
|
34
|
+
flex: 1;
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
gap: s(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.toast-header {
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
font-size: 16px;
|
|
43
|
+
line-height: 1.6;
|
|
44
|
+
color: color('text-body');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.toast-body {
|
|
48
|
+
font-size: 16px;
|
|
49
|
+
line-height: 1.6;
|
|
50
|
+
color: color('text-body');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Close button
|
|
54
|
+
.toast-close {
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
width: 24px;
|
|
57
|
+
height: 24px;
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
color: color('text-action');
|
|
63
|
+
background: none;
|
|
64
|
+
border: none;
|
|
65
|
+
padding: 0;
|
|
66
|
+
|
|
67
|
+
&:hover {
|
|
68
|
+
color: color('text-action-hover');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
i {
|
|
72
|
+
font-size: 16px;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Long variant (horizontal layout, left border)
|
|
77
|
+
&.toast-long {
|
|
78
|
+
.toast-content {
|
|
79
|
+
flex-direction: row;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: s(2);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.toast-header {
|
|
85
|
+
display: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.toast-body {
|
|
89
|
+
white-space: nowrap;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&:not(.toast-border) {
|
|
93
|
+
border-top: none;
|
|
94
|
+
border-left: 4px solid color('border');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Type 02 - Only full outline border (no thick colored border)
|
|
99
|
+
&.toast-border {
|
|
100
|
+
border: 1px solid color('border');
|
|
101
|
+
|
|
102
|
+
&.toast-long {
|
|
103
|
+
border: 1px solid color('border');
|
|
104
|
+
border-top: 1px solid color('border');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Color variants
|
|
109
|
+
&.info {
|
|
110
|
+
background-color: color('info-fill');
|
|
111
|
+
border-top-color: color('info');
|
|
112
|
+
|
|
113
|
+
.toast-icon {
|
|
114
|
+
color: color('info');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&.toast-long:not(.toast-border) {
|
|
118
|
+
border-top: none;
|
|
119
|
+
border-left-color: color('info');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&.toast-border {
|
|
123
|
+
border-color: color('info');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&.success {
|
|
128
|
+
background-color: color('success-fill');
|
|
129
|
+
border-top-color: color('success');
|
|
130
|
+
|
|
131
|
+
.toast-icon {
|
|
132
|
+
color: color('success');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
&.toast-long:not(.toast-border) {
|
|
136
|
+
border-top: none;
|
|
137
|
+
border-left-color: color('success');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
&.toast-border {
|
|
141
|
+
border-color: color('success');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
&.warning {
|
|
146
|
+
background-color: color('warning-fill');
|
|
147
|
+
border-top-color: color('warning');
|
|
148
|
+
|
|
149
|
+
.toast-icon {
|
|
150
|
+
color: color('warning');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
&.toast-long:not(.toast-border) {
|
|
154
|
+
border-top: none;
|
|
155
|
+
border-left-color: color('warning');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&.toast-border {
|
|
159
|
+
border-color: color('warning');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&.danger {
|
|
164
|
+
background-color: color('danger-fill');
|
|
165
|
+
border-top-color: color('danger');
|
|
166
|
+
|
|
167
|
+
.toast-icon {
|
|
168
|
+
color: color('danger');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
&.toast-long:not(.toast-border) {
|
|
172
|
+
border-top: none;
|
|
173
|
+
border-left-color: color('danger');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
&.toast-border {
|
|
177
|
+
border-color: color('danger');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Default variant (gray)
|
|
182
|
+
&.default {
|
|
183
|
+
background-color: color('default-fill');
|
|
184
|
+
border-top-color: color('default-deep');
|
|
185
|
+
|
|
186
|
+
.toast-icon {
|
|
187
|
+
color: color('text-action');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
&.toast-long:not(.toast-border) {
|
|
191
|
+
border-top: none;
|
|
192
|
+
border-left-color: color('default-deep');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
&.toast-border {
|
|
196
|
+
border-color: color('default-pressed');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Primary variant
|
|
201
|
+
&.primary {
|
|
202
|
+
background-color: color('primary-fill');
|
|
203
|
+
border-top-color: color('primary');
|
|
204
|
+
|
|
205
|
+
.toast-icon {
|
|
206
|
+
color: color('primary');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
&.toast-long:not(.toast-border) {
|
|
210
|
+
border-top: none;
|
|
211
|
+
border-left-color: color('primary');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
&.toast-border {
|
|
215
|
+
border-color: color('primary');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|