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
@@ -56,3 +56,4 @@
56
56
  @forward './scss/molecule/tab.scss';
57
57
  @forward './scss/molecule/table.scss';
58
58
  @forward './scss/molecule/pagination.scss';
59
+ @forward './scss/molecule/toast.scss';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "podo-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "author": "hada0127 <work@tarucy.net>",
6
6
  "license": "MIT",
@@ -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,12 @@
1
+ .toastAnimation {
2
+ opacity: 0;
3
+ transition: opacity 0.2s ease-in-out;
4
+
5
+ &.fadeIn {
6
+ opacity: 1;
7
+ }
8
+
9
+ &.fadeOut {
10
+ opacity: 0;
11
+ }
12
+ }
@@ -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
+ }