@apptimate/ui 1.0.0
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/CHANGELOG.md +7 -0
- package/package.json +18 -0
- package/src/Accordion.tsx +561 -0
- package/src/Badge.tsx +191 -0
- package/src/Button.tsx +353 -0
- package/src/ButtonGroup.tsx +149 -0
- package/src/Card.tsx +250 -0
- package/src/Checkbox.tsx +49 -0
- package/src/ChipInput.tsx +203 -0
- package/src/Divider.tsx +82 -0
- package/src/Dropdown.tsx +128 -0
- package/src/EmptyState.tsx +18 -0
- package/src/HintIcon.tsx +49 -0
- package/src/Input.tsx +60 -0
- package/src/Modal.tsx +98 -0
- package/src/Pagination.tsx +51 -0
- package/src/PopConfirm.tsx +350 -0
- package/src/SearchableSelect.tsx +735 -0
- package/src/Select.tsx +49 -0
- package/src/Table.tsx +78 -0
- package/src/index.tsx +20 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
6
|
+
import { block, modifier } from '@apptimate/core-lib';
|
|
7
|
+
import { cn } from '@apptimate/core-lib';
|
|
8
|
+
|
|
9
|
+
export type PopConfirmPlacement = 'top' | 'bottom' | 'left' | 'right';
|
|
10
|
+
|
|
11
|
+
export interface PopConfirmProps {
|
|
12
|
+
/** Element that triggers the popover (e.g. Button) */
|
|
13
|
+
trigger: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }>;
|
|
14
|
+
|
|
15
|
+
/** Called when user confirms. Call callback() to close. setLoading controls confirm button loading state. */
|
|
16
|
+
onConfirm: (callback: () => void, setLoading: (loading: boolean) => void) => void;
|
|
17
|
+
|
|
18
|
+
/** Called when user cancels. Call callback() to close. */
|
|
19
|
+
onCancel: (callback: () => void) => void;
|
|
20
|
+
|
|
21
|
+
/** Text for the confirm button (default: 'Confirm') */
|
|
22
|
+
confirmText?: string;
|
|
23
|
+
|
|
24
|
+
/** Text for the cancel button (default: 'Cancel') */
|
|
25
|
+
cancelText?: string;
|
|
26
|
+
|
|
27
|
+
/** Title shown in the popover */
|
|
28
|
+
title?: string;
|
|
29
|
+
|
|
30
|
+
/** Body/description text shown in the popover */
|
|
31
|
+
body?: string;
|
|
32
|
+
|
|
33
|
+
/** Optional icon shown in the popover */
|
|
34
|
+
icon?: React.ReactNode;
|
|
35
|
+
|
|
36
|
+
/** Placement of the popover relative to the trigger */
|
|
37
|
+
placement?: PopConfirmPlacement;
|
|
38
|
+
|
|
39
|
+
/** BEM class name prefix (default 'aceui') */
|
|
40
|
+
prefix?: string;
|
|
41
|
+
|
|
42
|
+
/** Additional class name for the popover */
|
|
43
|
+
className?: string;
|
|
44
|
+
|
|
45
|
+
/** Additional class name for the trigger wrapper (default: 'inline-block') */
|
|
46
|
+
triggerClassName?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const defaultConfirmText = 'Yes';
|
|
50
|
+
const defaultTitleText = 'Confirmation';
|
|
51
|
+
const defaultBodyText = 'Are you sure?';
|
|
52
|
+
const defaultCancelText = 'Cancel';
|
|
53
|
+
const defaultIcon = <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"></path><path d="M12 16V12M12 8H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path></svg>
|
|
54
|
+
|
|
55
|
+
export const PopConfirm = ({
|
|
56
|
+
trigger,
|
|
57
|
+
onConfirm,
|
|
58
|
+
onCancel,
|
|
59
|
+
confirmText = defaultConfirmText,
|
|
60
|
+
cancelText = defaultCancelText,
|
|
61
|
+
title = defaultTitleText,
|
|
62
|
+
body = defaultBodyText,
|
|
63
|
+
icon = defaultIcon,
|
|
64
|
+
placement = 'bottom',
|
|
65
|
+
prefix = 'aceui',
|
|
66
|
+
className,
|
|
67
|
+
triggerClassName,
|
|
68
|
+
}: PopConfirmProps) => {
|
|
69
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
70
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
71
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
72
|
+
const popoverRef = useRef<HTMLDivElement>(null);
|
|
73
|
+
|
|
74
|
+
const close = useCallback(() => {
|
|
75
|
+
setIsOpen(false);
|
|
76
|
+
setIsLoading(false);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const handleConfirm = useCallback(() => {
|
|
80
|
+
onConfirm(close, setIsLoading);
|
|
81
|
+
}, [onConfirm, close]);
|
|
82
|
+
|
|
83
|
+
const handleCancel = useCallback(() => {
|
|
84
|
+
onCancel(close);
|
|
85
|
+
}, [onCancel, close]);
|
|
86
|
+
|
|
87
|
+
const handleTriggerClick = useCallback(
|
|
88
|
+
(e: React.MouseEvent) => {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
setIsOpen((prev) => !prev);
|
|
92
|
+
},
|
|
93
|
+
[]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const triggerWithProps = React.cloneElement(trigger, {
|
|
97
|
+
onClick: (e: React.MouseEvent) => {
|
|
98
|
+
trigger.props.onClick?.(e);
|
|
99
|
+
handleTriggerClick(e);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!isOpen) return;
|
|
105
|
+
|
|
106
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
107
|
+
const target = e.target as Node;
|
|
108
|
+
if (
|
|
109
|
+
popoverRef.current?.contains(target) ||
|
|
110
|
+
triggerRef.current?.contains(target)
|
|
111
|
+
) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
close();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
118
|
+
if (e.key === 'Escape') close();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
122
|
+
document.addEventListener('keydown', handleEscape);
|
|
123
|
+
return () => {
|
|
124
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
125
|
+
document.removeEventListener('keydown', handleEscape);
|
|
126
|
+
};
|
|
127
|
+
}, [isOpen, close]);
|
|
128
|
+
|
|
129
|
+
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
|
130
|
+
const [resolvedPlacement, setResolvedPlacement] = useState<PopConfirmPlacement>(placement);
|
|
131
|
+
|
|
132
|
+
const updatePosition = useCallback(() => {
|
|
133
|
+
const triggerEl = triggerRef.current;
|
|
134
|
+
if (!triggerEl) return;
|
|
135
|
+
|
|
136
|
+
const rect = triggerEl.getBoundingClientRect();
|
|
137
|
+
const gap = 8;
|
|
138
|
+
const popoverWidth = 280;
|
|
139
|
+
const popoverHeight = 120;
|
|
140
|
+
const viewportWidth = window.innerWidth;
|
|
141
|
+
const viewportHeight = window.innerHeight;
|
|
142
|
+
|
|
143
|
+
const calcPosition = (p: PopConfirmPlacement) => {
|
|
144
|
+
let top = 0;
|
|
145
|
+
let left = 0;
|
|
146
|
+
switch (p) {
|
|
147
|
+
case 'top':
|
|
148
|
+
top = rect.top - popoverHeight - gap;
|
|
149
|
+
left = rect.left + rect.width / 2 - popoverWidth / 2;
|
|
150
|
+
break;
|
|
151
|
+
case 'bottom':
|
|
152
|
+
top = rect.bottom + gap;
|
|
153
|
+
left = rect.left + rect.width / 2 - popoverWidth / 2;
|
|
154
|
+
break;
|
|
155
|
+
case 'left':
|
|
156
|
+
top = rect.top + rect.height / 2 - popoverHeight / 2;
|
|
157
|
+
left = rect.left - popoverWidth - gap;
|
|
158
|
+
break;
|
|
159
|
+
case 'right':
|
|
160
|
+
top = rect.top + rect.height / 2 - popoverHeight / 2;
|
|
161
|
+
left = rect.right + gap;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
return { top, left };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const fitsInViewport = (pos: { top: number; left: number }) => {
|
|
168
|
+
return (
|
|
169
|
+
pos.top >= 0 &&
|
|
170
|
+
pos.left >= 0 &&
|
|
171
|
+
pos.top + popoverHeight <= viewportHeight &&
|
|
172
|
+
pos.left + popoverWidth <= viewportWidth
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Try preferred placement first
|
|
177
|
+
let pos = calcPosition(placement);
|
|
178
|
+
if (fitsInViewport(pos)) {
|
|
179
|
+
setResolvedPlacement(placement);
|
|
180
|
+
// Clamp left to prevent horizontal overflow
|
|
181
|
+
pos.left = Math.max(8, Math.min(pos.left, viewportWidth - popoverWidth - 8));
|
|
182
|
+
setPosition(pos);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Try opposite placement
|
|
187
|
+
const opposite: Record<PopConfirmPlacement, PopConfirmPlacement> = {
|
|
188
|
+
top: 'bottom', bottom: 'top', left: 'right', right: 'left',
|
|
189
|
+
};
|
|
190
|
+
const fallbackOrder: PopConfirmPlacement[] = [
|
|
191
|
+
opposite[placement],
|
|
192
|
+
...(['top', 'bottom', 'left', 'right'] as PopConfirmPlacement[]).filter(
|
|
193
|
+
(p) => p !== placement && p !== opposite[placement]
|
|
194
|
+
),
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
for (const fallback of fallbackOrder) {
|
|
198
|
+
const fallbackPos = calcPosition(fallback);
|
|
199
|
+
if (fitsInViewport(fallbackPos)) {
|
|
200
|
+
setResolvedPlacement(fallback);
|
|
201
|
+
fallbackPos.left = Math.max(8, Math.min(fallbackPos.left, viewportWidth - popoverWidth - 8));
|
|
202
|
+
setPosition(fallbackPos);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// None fit perfectly — use opposite and clamp
|
|
208
|
+
const finalPlacement = opposite[placement];
|
|
209
|
+
const finalPos = calcPosition(finalPlacement);
|
|
210
|
+
finalPos.top = Math.max(8, Math.min(finalPos.top, viewportHeight - popoverHeight - 8));
|
|
211
|
+
finalPos.left = Math.max(8, Math.min(finalPos.left, viewportWidth - popoverWidth - 8));
|
|
212
|
+
setResolvedPlacement(finalPlacement);
|
|
213
|
+
setPosition(finalPos);
|
|
214
|
+
}, [placement]);
|
|
215
|
+
|
|
216
|
+
useLayoutEffect(() => {
|
|
217
|
+
if (!isOpen || typeof document === 'undefined') return;
|
|
218
|
+
|
|
219
|
+
updatePosition();
|
|
220
|
+
|
|
221
|
+
const triggerEl = triggerRef.current;
|
|
222
|
+
if (!triggerEl) return;
|
|
223
|
+
|
|
224
|
+
const resizeObserver = new ResizeObserver(updatePosition);
|
|
225
|
+
resizeObserver.observe(triggerEl);
|
|
226
|
+
|
|
227
|
+
return () => resizeObserver.disconnect();
|
|
228
|
+
}, [isOpen, placement, updatePosition]);
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!isOpen) setPosition(null);
|
|
232
|
+
}, [isOpen]);
|
|
233
|
+
|
|
234
|
+
const blockClass = block(prefix, 'pop-confirm');
|
|
235
|
+
const bemClasses = [
|
|
236
|
+
blockClass,
|
|
237
|
+
modifier(prefix, 'pop-confirm', resolvedPlacement),
|
|
238
|
+
].filter(Boolean) as string[];
|
|
239
|
+
|
|
240
|
+
const popoverContent = (
|
|
241
|
+
<AnimatePresence>
|
|
242
|
+
{isOpen && position && (
|
|
243
|
+
<>
|
|
244
|
+
<motion.div
|
|
245
|
+
className="fixed inset-0 z-[9998] bg-transparent"
|
|
246
|
+
onClick={(e) => e.stopPropagation()}
|
|
247
|
+
initial={{ opacity: 0 }}
|
|
248
|
+
animate={{ opacity: 1 }}
|
|
249
|
+
exit={{ opacity: 0 }}
|
|
250
|
+
transition={{ duration: 0.15 }}
|
|
251
|
+
aria-hidden="true"
|
|
252
|
+
/>
|
|
253
|
+
<motion.div
|
|
254
|
+
ref={popoverRef}
|
|
255
|
+
role="dialog"
|
|
256
|
+
aria-modal="true"
|
|
257
|
+
aria-labelledby={title ? `${blockClass}-title` : undefined}
|
|
258
|
+
aria-describedby={body ? `${blockClass}-body` : undefined}
|
|
259
|
+
onClick={(e) => e.stopPropagation()}
|
|
260
|
+
className={cn(
|
|
261
|
+
bemClasses,
|
|
262
|
+
'fixed z-[9999] w-[280px] min-h-[80px] rounded-[12px] bg-surface-1 shadow-[0_4px_24px_rgba(0,0,0,0.12)] p-4 flex flex-col gap-3',
|
|
263
|
+
className
|
|
264
|
+
)}
|
|
265
|
+
style={{
|
|
266
|
+
top: position.top,
|
|
267
|
+
left: position.left,
|
|
268
|
+
}}
|
|
269
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
270
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
271
|
+
exit={{ opacity: 0, scale: 0.95 }}
|
|
272
|
+
transition={{ duration: 0.15 }}
|
|
273
|
+
>
|
|
274
|
+
{/* Arrow pointer */}
|
|
275
|
+
<div
|
|
276
|
+
className={cn(
|
|
277
|
+
'absolute w-3 h-3 bg-surface-1 rotate-45 shadow-[0_4px_24px_rgba(0,0,0,0.12)]',
|
|
278
|
+
resolvedPlacement === 'bottom' && 'top-[-6px] left-1/2 -translate-x-1/2',
|
|
279
|
+
resolvedPlacement === 'top' && 'bottom-[-6px] left-1/2 -translate-x-1/2',
|
|
280
|
+
resolvedPlacement === 'left' && 'right-[-6px] top-1/2 -translate-y-1/2',
|
|
281
|
+
resolvedPlacement === 'right' && 'left-[-6px] top-1/2 -translate-y-1/2',
|
|
282
|
+
)}
|
|
283
|
+
/>
|
|
284
|
+
{(title || body || icon) && (
|
|
285
|
+
<div className="flex gap-3">
|
|
286
|
+
{icon && (
|
|
287
|
+
<div className="flex-shrink-0 text-primary-500 [&>svg]:w-5 [&>svg]:h-5">
|
|
288
|
+
{icon}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
<div className="flex-1 min-w-0">
|
|
292
|
+
{title && (
|
|
293
|
+
<h3
|
|
294
|
+
id={`${blockClass}-title`}
|
|
295
|
+
className="text-sm font-semibold text-foreground-0"
|
|
296
|
+
>
|
|
297
|
+
{title}
|
|
298
|
+
</h3>
|
|
299
|
+
)}
|
|
300
|
+
{body && (
|
|
301
|
+
<p
|
|
302
|
+
id={`${blockClass}-body`}
|
|
303
|
+
className="text-sm text-muted-foreground mt-0.5"
|
|
304
|
+
>
|
|
305
|
+
{body}
|
|
306
|
+
</p>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
<div className="flex justify-end gap-2 mt-1">
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
onClick={handleCancel}
|
|
315
|
+
disabled={isLoading}
|
|
316
|
+
className="px-3 py-1.5 text-sm font-medium rounded-[8px] bg-default-100 text-default-700 hover:bg-default-200 transition-colors disabled:opacity-50 cursor-pointer"
|
|
317
|
+
>
|
|
318
|
+
{cancelText}
|
|
319
|
+
</button>
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={handleConfirm}
|
|
323
|
+
disabled={isLoading}
|
|
324
|
+
className="px-3 py-1.5 text-sm font-medium rounded-[8px] bg-primary-500 text-primary-foreground hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer inline-flex items-center gap-2"
|
|
325
|
+
>
|
|
326
|
+
{isLoading && (
|
|
327
|
+
<span
|
|
328
|
+
className="inline-block w-3.5 h-3.5 border-2 border-current border-r-transparent rounded-full animate-spin"
|
|
329
|
+
aria-hidden="true"
|
|
330
|
+
/>
|
|
331
|
+
)}
|
|
332
|
+
{confirmText}
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
</motion.div>
|
|
336
|
+
</>
|
|
337
|
+
)}
|
|
338
|
+
</AnimatePresence>
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
<div ref={triggerRef} className={triggerClassName || 'inline-block'}>
|
|
343
|
+
{triggerWithProps}
|
|
344
|
+
{typeof document !== 'undefined' &&
|
|
345
|
+
createPortal(popoverContent, document.body)}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
PopConfirm.displayName = 'PopConfirm';
|