@apptimate/ui 1.1.0 → 1.3.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/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "main": "src/index.tsx",
5
5
  "types": "src/index.tsx",
6
6
  "dependencies": {
7
+ "@apptimate/core-lib": "*",
7
8
  "class-variance-authority": "^0.7.1",
8
9
  "clsx": "^2.1.1",
9
10
  "framer-motion": "^12.38.0",
10
11
  "lucide-react": "^1.14.0",
11
12
  "react": "^19.2.5",
12
- "tailwind-merge": "^3.5.0",
13
- "@apptimate/core-lib": "*"
13
+ "recharts": "^2.15.4",
14
+ "tailwind-merge": "^3.5.0"
14
15
  },
15
16
  "publishConfig": {
16
17
  "access": "public"
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+
3
+ export interface DateLabelProps {
4
+ date: string | Date;
5
+ className?: string;
6
+ }
7
+
8
+ export function DateLabel({ date, className = '' }: DateLabelProps) {
9
+ if (!date) return <span className="text-gray-300">-</span>;
10
+
11
+ const d = new Date(date);
12
+
13
+ // Format to "2026-05-07 4:35PM"
14
+ const year = d.getFullYear();
15
+ const month = String(d.getMonth() + 1).padStart(2, '0');
16
+ const day = String(d.getDate()).padStart(2, '0');
17
+ let hours = d.getHours();
18
+ const minutes = String(d.getMinutes()).padStart(2, '0');
19
+ const ampm = hours >= 12 ? 'PM' : 'AM';
20
+ hours = hours % 12;
21
+ hours = hours ? hours : 12; // the hour '0' should be '12'
22
+
23
+ const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}${ampm}`;
24
+
25
+ // Calculate relative time
26
+ const now = new Date();
27
+ const diffInSeconds = Math.floor((now.getTime() - d.getTime()) / 1000);
28
+
29
+ let relativeText = null;
30
+
31
+ if (diffInSeconds < 60) {
32
+ relativeText = 'just now';
33
+ } else if (diffInSeconds < 3600) {
34
+ const mins = Math.floor(diffInSeconds / 60);
35
+ relativeText = `${mins} min${mins !== 1 ? 's' : ''} ago`;
36
+ } else if (diffInSeconds < 86400) {
37
+ // Less than 24 hours ago
38
+ const hours = Math.floor(diffInSeconds / 3600);
39
+
40
+ // Check if it's the same calendar day or previous
41
+ const isSameDay = now.getDate() === d.getDate() && now.getMonth() === d.getMonth() && now.getFullYear() === d.getFullYear();
42
+
43
+ if (isSameDay) {
44
+ relativeText = `${hours} hour${hours !== 1 ? 's' : ''} ago`;
45
+ } else {
46
+ relativeText = 'yesterday';
47
+ }
48
+ } else if (diffInSeconds < 172800) {
49
+ // Between 24 and 48 hours ago
50
+ const yesterday = new Date(now);
51
+ yesterday.setDate(now.getDate() - 1);
52
+
53
+ const isYesterday = yesterday.getDate() === d.getDate() && yesterday.getMonth() === d.getMonth() && yesterday.getFullYear() === d.getFullYear();
54
+
55
+ if (isYesterday) {
56
+ relativeText = 'yesterday';
57
+ }
58
+ }
59
+
60
+ return (
61
+ <div className={`flex flex-col ${className}`}>
62
+ <span className="text-gray-600 font-medium whitespace-nowrap">{formattedDate}</span>
63
+ {relativeText && (
64
+ <span className="text-[10.5px] text-gray-400 font-semibold">{relativeText}</span>
65
+ )}
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,392 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { Calendar, ChevronDown, X, Check } from 'lucide-react';
6
+ import { cn } from '@apptimate/core-lib';
7
+
8
+ // ── Preset definitions ──────────────────────────────────────────────
9
+ export type DateRangePresetKey =
10
+ | 'today'
11
+ | 'yesterday'
12
+ | 'this_week'
13
+ | 'last_7_days'
14
+ | 'this_month'
15
+ | 'last_30_days'
16
+ | 'this_year'
17
+ | 'custom';
18
+
19
+ export interface DateRange {
20
+ from: string; // YYYY-MM-DD
21
+ to: string; // YYYY-MM-DD
22
+ }
23
+
24
+ export interface DateRangePreset {
25
+ key: DateRangePresetKey;
26
+ label: string;
27
+ resolve: () => DateRange;
28
+ }
29
+
30
+ const fmt = (d: Date): string => {
31
+ const y = d.getFullYear();
32
+ const m = String(d.getMonth() + 1).padStart(2, '0');
33
+ const day = String(d.getDate()).padStart(2, '0');
34
+ return `${y}-${m}-${day}`;
35
+ };
36
+
37
+ const today = (): Date => {
38
+ const d = new Date();
39
+ d.setHours(0, 0, 0, 0);
40
+ return d;
41
+ };
42
+
43
+ const PRESETS: DateRangePreset[] = [
44
+ {
45
+ key: 'today',
46
+ label: 'Today',
47
+ resolve: () => {
48
+ const d = fmt(today());
49
+ return { from: d, to: d };
50
+ },
51
+ },
52
+ {
53
+ key: 'yesterday',
54
+ label: 'Yesterday',
55
+ resolve: () => {
56
+ const d = today();
57
+ d.setDate(d.getDate() - 1);
58
+ const s = fmt(d);
59
+ return { from: s, to: s };
60
+ },
61
+ },
62
+ {
63
+ key: 'this_week',
64
+ label: 'This Week',
65
+ resolve: () => {
66
+ const d = today();
67
+ const day = d.getDay(); // 0=Sun
68
+ const diff = day === 0 ? 6 : day - 1; // monday start
69
+ const start = new Date(d);
70
+ start.setDate(d.getDate() - diff);
71
+ return { from: fmt(start), to: fmt(d) };
72
+ },
73
+ },
74
+ {
75
+ key: 'last_7_days',
76
+ label: 'Last 7 Days',
77
+ resolve: () => {
78
+ const d = today();
79
+ const start = new Date(d);
80
+ start.setDate(d.getDate() - 6);
81
+ return { from: fmt(start), to: fmt(d) };
82
+ },
83
+ },
84
+ {
85
+ key: 'this_month',
86
+ label: 'This Month',
87
+ resolve: () => {
88
+ const d = today();
89
+ const start = new Date(d.getFullYear(), d.getMonth(), 1);
90
+ return { from: fmt(start), to: fmt(d) };
91
+ },
92
+ },
93
+ {
94
+ key: 'last_30_days',
95
+ label: 'Last 30 Days',
96
+ resolve: () => {
97
+ const d = today();
98
+ const start = new Date(d);
99
+ start.setDate(d.getDate() - 29);
100
+ return { from: fmt(start), to: fmt(d) };
101
+ },
102
+ },
103
+ {
104
+ key: 'this_year',
105
+ label: 'This Year',
106
+ resolve: () => {
107
+ const d = today();
108
+ const start = new Date(d.getFullYear(), 0, 1);
109
+ return { from: fmt(start), to: fmt(d) };
110
+ },
111
+ },
112
+ {
113
+ key: 'custom',
114
+ label: 'Custom Range',
115
+ resolve: () => {
116
+ const d = fmt(today());
117
+ return { from: d, to: d };
118
+ },
119
+ },
120
+ ];
121
+
122
+ // ── Props ──────────────────────────────────────────────────────────
123
+ export interface DateRangePickerProps {
124
+ /** Currently selected preset key */
125
+ value: DateRangePresetKey;
126
+ /** The resolved from/to dates */
127
+ dateRange: DateRange;
128
+ /** Callback when selection changes */
129
+ onChange: (preset: DateRangePresetKey, range: DateRange) => void;
130
+ /** Additional className for the trigger */
131
+ className?: string;
132
+ /** Alignment of the dropdown */
133
+ align?: 'left' | 'right';
134
+ }
135
+
136
+ // ── Helpers ──────────────────────────────────────────────────────────
137
+ const formatDisplayDate = (dateStr: string): string => {
138
+ if (!dateStr) return '';
139
+ const d = new Date(dateStr + 'T00:00:00');
140
+ return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
141
+ };
142
+
143
+ const getDisplayLabel = (preset: DateRangePresetKey, range: DateRange): string => {
144
+ const p = PRESETS.find((pr) => pr.key === preset);
145
+ if (!p) return '';
146
+ if (preset === 'custom') {
147
+ if (range.from === range.to) return formatDisplayDate(range.from);
148
+ return `${formatDisplayDate(range.from)} – ${formatDisplayDate(range.to)}`;
149
+ }
150
+ return p.label;
151
+ };
152
+
153
+ // ── Component ──────────────────────────────────────────────────────
154
+ export const DateRangePicker = ({
155
+ value,
156
+ dateRange,
157
+ onChange,
158
+ className,
159
+ align = 'left',
160
+ }: DateRangePickerProps) => {
161
+ const [isOpen, setIsOpen] = useState(false);
162
+ const [coords, setCoords] = useState({ top: 0, bottom: 0, left: 0, right: 0 });
163
+ const triggerRef = useRef<HTMLDivElement>(null);
164
+ const contentRef = useRef<HTMLDivElement>(null);
165
+
166
+ // Custom-range local state (only used when editing custom)
167
+ const [customFrom, setCustomFrom] = useState(dateRange.from);
168
+ const [customTo, setCustomTo] = useState(dateRange.to);
169
+ const [showCustomPanel, setShowCustomPanel] = useState(value === 'custom');
170
+
171
+ // Sync custom state when dateRange changes externally
172
+ useEffect(() => {
173
+ setCustomFrom(dateRange.from);
174
+ setCustomTo(dateRange.to);
175
+ }, [dateRange.from, dateRange.to]);
176
+
177
+ const updatePosition = useCallback(() => {
178
+ if (triggerRef.current) {
179
+ const rect = triggerRef.current.getBoundingClientRect();
180
+ setCoords({
181
+ top: rect.top + window.scrollY,
182
+ bottom: rect.bottom + window.scrollY,
183
+ left: rect.left + window.scrollX,
184
+ right: window.innerWidth - rect.right - window.scrollX,
185
+ });
186
+ }
187
+ }, []);
188
+
189
+ const toggleDropdown = () => {
190
+ if (!isOpen) {
191
+ updatePosition();
192
+ setShowCustomPanel(value === 'custom');
193
+ }
194
+ setIsOpen((prev) => !prev);
195
+ };
196
+
197
+ const closeDropdown = () => setIsOpen(false);
198
+
199
+ // Close on outside click
200
+ useEffect(() => {
201
+ const handleClickOutside = (e: MouseEvent) => {
202
+ const target = e.target as Element;
203
+ const clickedTrigger = triggerRef.current?.contains(target as Node);
204
+ const clickedContent = contentRef.current?.contains(target as Node);
205
+ if (!clickedTrigger && !clickedContent) {
206
+ setIsOpen(false);
207
+ }
208
+ };
209
+ const handleScroll = () => { if (isOpen) updatePosition(); };
210
+ const handleResize = () => { if (isOpen) updatePosition(); };
211
+
212
+ if (isOpen) {
213
+ document.addEventListener('mousedown', handleClickOutside);
214
+ window.addEventListener('scroll', handleScroll, true);
215
+ window.addEventListener('resize', handleResize);
216
+ }
217
+ return () => {
218
+ document.removeEventListener('mousedown', handleClickOutside);
219
+ window.removeEventListener('scroll', handleScroll, true);
220
+ window.removeEventListener('resize', handleResize);
221
+ };
222
+ }, [isOpen, updatePosition]);
223
+
224
+ const handlePresetClick = (preset: DateRangePreset) => {
225
+ if (preset.key === 'custom') {
226
+ setShowCustomPanel(true);
227
+ return; // don't close, show custom date inputs
228
+ }
229
+ const resolved = preset.resolve();
230
+ onChange(preset.key, resolved);
231
+ setShowCustomPanel(false);
232
+ closeDropdown();
233
+ };
234
+
235
+ const handleCustomApply = () => {
236
+ if (customFrom && customTo) {
237
+ // ensure from <= to
238
+ const f = customFrom <= customTo ? customFrom : customTo;
239
+ const t = customFrom <= customTo ? customTo : customFrom;
240
+ onChange('custom', { from: f, to: t });
241
+ closeDropdown();
242
+ }
243
+ };
244
+
245
+ const displayLabel = useMemo(() => getDisplayLabel(value, dateRange), [value, dateRange]);
246
+
247
+ return (
248
+ <>
249
+ {/* Trigger */}
250
+ <div ref={triggerRef} className={cn("relative inline-block", className)}>
251
+ <button
252
+ type="button"
253
+ onClick={toggleDropdown}
254
+ className={cn(
255
+ "flex items-center gap-2 px-3 py-2.5 rounded-xl text-[12.5px] font-bold border transition-all",
256
+ "bg-white border-gray-200 hover:border-gray-300 text-gray-700",
257
+ isOpen && "border-primary-300 ring-2 ring-primary-100"
258
+ )}
259
+ >
260
+ <Calendar size={14} strokeWidth={2.5} className="text-gray-400 flex-shrink-0" />
261
+ <span className="truncate max-w-[180px]">{displayLabel}</span>
262
+ <ChevronDown
263
+ size={13}
264
+ strokeWidth={2.5}
265
+ className={cn(
266
+ "text-gray-400 transition-transform flex-shrink-0",
267
+ isOpen && "rotate-180"
268
+ )}
269
+ />
270
+ </button>
271
+ </div>
272
+
273
+ {/* Dropdown via portal */}
274
+ {isOpen && typeof document !== 'undefined' && createPortal(
275
+ <div
276
+ ref={contentRef}
277
+ style={{
278
+ position: 'absolute',
279
+ top: `${coords.bottom}px`,
280
+ ...(align === 'left'
281
+ ? { left: `${coords.left}px` }
282
+ : { right: `${coords.right}px` }),
283
+ }}
284
+ className="z-[9999] mt-1.5 rounded-[12px] bg-white border border-gray-200 shadow-[0_8px_30px_rgb(0,0,0,0.12)] animate-in fade-in zoom-in-95 duration-200 overflow-hidden"
285
+ >
286
+ <div className="flex">
287
+ {/* Preset list */}
288
+ <div className={cn(
289
+ "py-2 min-w-[180px]",
290
+ showCustomPanel && "border-r border-gray-100"
291
+ )}>
292
+ <p className="px-3 pb-1.5 pt-1 text-[10px] font-bold text-gray-400 uppercase tracking-wider">
293
+ Date Range
294
+ </p>
295
+ {PRESETS.map((preset) => (
296
+ <button
297
+ key={preset.key}
298
+ type="button"
299
+ onClick={() => handlePresetClick(preset)}
300
+ className={cn(
301
+ "w-full px-3 py-2 text-[13px] text-left transition-colors flex items-center gap-2",
302
+ (value === preset.key && !(preset.key === 'custom' && !showCustomPanel))
303
+ ? "bg-primary-50 text-primary-700 font-bold"
304
+ : "text-gray-700 hover:bg-gray-50 font-medium"
305
+ )}
306
+ >
307
+ {value === preset.key && (
308
+ <Check size={13} strokeWidth={3} className="text-primary-600 flex-shrink-0" />
309
+ )}
310
+ <span className={cn(value !== preset.key && "pl-[21px]")}>
311
+ {preset.label}
312
+ </span>
313
+ </button>
314
+ ))}
315
+ </div>
316
+
317
+ {/* Custom date panel */}
318
+ {showCustomPanel && (
319
+ <div className="p-3 min-w-[220px] flex flex-col gap-3">
320
+ <p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">
321
+ Custom Range
322
+ </p>
323
+ <div className="space-y-2">
324
+ <div>
325
+ <label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1 block">
326
+ From
327
+ </label>
328
+ <input
329
+ type="date"
330
+ value={customFrom}
331
+ onChange={(e) => setCustomFrom(e.target.value)}
332
+ className="appearance-none bg-white border border-gray-200 rounded-lg px-3 py-2 text-[12.5px] font-semibold text-gray-700 outline-none transition-all hover:border-gray-300 focus:border-primary-300 focus:ring-2 focus:ring-primary-100 w-full"
333
+ />
334
+ </div>
335
+ <div>
336
+ <label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1 block">
337
+ To
338
+ </label>
339
+ <input
340
+ type="date"
341
+ value={customTo}
342
+ onChange={(e) => setCustomTo(e.target.value)}
343
+ className="appearance-none bg-white border border-gray-200 rounded-lg px-3 py-2 text-[12.5px] font-semibold text-gray-700 outline-none transition-all hover:border-gray-300 focus:border-primary-300 focus:ring-2 focus:ring-primary-100 w-full"
344
+ />
345
+ </div>
346
+ </div>
347
+ <button
348
+ type="button"
349
+ onClick={handleCustomApply}
350
+ disabled={!customFrom || !customTo}
351
+ className={cn(
352
+ "w-full py-2 rounded-lg text-[12.5px] font-bold transition-all",
353
+ customFrom && customTo
354
+ ? "bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700"
355
+ : "bg-gray-100 text-gray-400 cursor-not-allowed"
356
+ )}
357
+ >
358
+ Apply Range
359
+ </button>
360
+ </div>
361
+ )}
362
+ </div>
363
+
364
+ {/* Footer showing resolved range */}
365
+ {value !== 'custom' && (
366
+ <div className="px-3 py-2 border-t border-gray-100 bg-gray-50/50">
367
+ <p className="text-[11px] text-gray-500 font-medium">
368
+ <span className="font-bold text-gray-600">{formatDisplayDate(dateRange.from)}</span>
369
+ {dateRange.from !== dateRange.to && (
370
+ <>
371
+ <span className="mx-1">→</span>
372
+ <span className="font-bold text-gray-600">{formatDisplayDate(dateRange.to)}</span>
373
+ </>
374
+ )}
375
+ </p>
376
+ </div>
377
+ )}
378
+ </div>,
379
+ document.body
380
+ )}
381
+ </>
382
+ );
383
+ };
384
+
385
+ // ── Utility: resolve a preset key to a DateRange ───────────────────
386
+ export const resolveDateRangePreset = (key: DateRangePresetKey): DateRange => {
387
+ const preset = PRESETS.find((p) => p.key === key);
388
+ if (!preset) return { from: fmt(today()), to: fmt(today()) };
389
+ return preset.resolve();
390
+ };
391
+
392
+ DateRangePicker.displayName = 'DateRangePicker';
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import React, { useRef } from "react";
4
+ import { Upload } from "lucide-react";
5
+
6
+ export interface ImageDropzoneProps {
7
+ onDrop: (files: File[]) => void;
8
+ maxSizeMB?: number;
9
+ maxFiles?: number;
10
+ accept?: string;
11
+ className?: string;
12
+ title?: string;
13
+ subtitle?: string;
14
+ isLoading?: boolean;
15
+ }
16
+
17
+ export function ImageDropzone({
18
+ onDrop,
19
+ maxSizeMB = 5,
20
+ maxFiles = 10,
21
+ accept = "image/*",
22
+ className = "",
23
+ title = "Drop images here or click to upload",
24
+ subtitle = "JPEG, PNG, WebP — max 5MB each, up to 10 images",
25
+ isLoading = false,
26
+ }: ImageDropzoneProps) {
27
+ const fileInputRef = useRef<HTMLInputElement>(null);
28
+
29
+ const handleFileDrop = (e: React.DragEvent) => {
30
+ e.preventDefault();
31
+ if (isLoading) return;
32
+ const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
33
+ if (files.length) {
34
+ onDrop(files.slice(0, maxFiles));
35
+ }
36
+ };
37
+
38
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
39
+ if (isLoading) return;
40
+ const files = Array.from(e.target.files || []);
41
+ if (files.length) {
42
+ onDrop(files.slice(0, maxFiles));
43
+ }
44
+ if (fileInputRef.current) {
45
+ fileInputRef.current.value = "";
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div
51
+ onDragOver={(e) => e.preventDefault()}
52
+ onDrop={handleFileDrop}
53
+ onClick={() => fileInputRef.current?.click()}
54
+ className={`border-2 border-dashed border-gray-200 hover:border-primary-300 rounded-xl p-6 flex flex-col items-center justify-center cursor-pointer transition-all hover:bg-primary-50/30 group ${
55
+ isLoading ? "opacity-50 cursor-not-allowed" : ""
56
+ } ${className}`}
57
+ >
58
+ <div className="w-10 h-10 rounded-full bg-gray-100 group-hover:bg-primary-100 flex items-center justify-center mb-2 transition-colors">
59
+ {isLoading ? (
60
+ <div className="w-5 h-5 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
61
+ ) : (
62
+ <Upload size={18} className="text-gray-400 group-hover:text-primary-500" />
63
+ )}
64
+ </div>
65
+ <p className="text-[13px] font-medium text-gray-500 group-hover:text-primary-600">
66
+ {isLoading ? "Uploading..." : title}
67
+ </p>
68
+ <p className="text-[11px] text-gray-400 mt-0.5">{subtitle}</p>
69
+ <input
70
+ ref={fileInputRef}
71
+ type="file"
72
+ accept={accept}
73
+ multiple={maxFiles > 1}
74
+ className="hidden"
75
+ onChange={handleFileSelect}
76
+ disabled={isLoading}
77
+ />
78
+ </div>
79
+ );
80
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import React, { useEffect } from 'react';
3
+ import React, { useEffect, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { motion, AnimatePresence } from 'framer-motion';
5
6
  import { X } from 'lucide-react';
6
7
  import { cn } from '@apptimate/core-lib';
@@ -15,9 +16,15 @@ export interface ModalProps {
15
16
  backdrop?: 'transparent' | 'opaque' | 'blur';
16
17
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
17
18
  position?: 'center' | 'bottom';
19
+ /** Override z-index layer (default 50). Increase for nested modals. */
20
+ zIndex?: number;
18
21
  }
19
22
 
20
- export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'center' }: ModalProps) => {
23
+ export const Modal = ({ isOpen, onClose, title, children, className, footer, backdrop = 'opaque', size = 'sm', position = 'center', zIndex }: ModalProps) => {
24
+ // SSR-safe portal mount guard — document is only available on the client
25
+ const [mounted, setMounted] = useState(false);
26
+ useEffect(() => { setMounted(true); }, []);
27
+
21
28
  useEffect(() => {
22
29
  if (isOpen) {
23
30
  document.body.style.overflow = 'hidden';
@@ -44,14 +51,14 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
44
51
  full: 'max-w-[100vw]',
45
52
  };
46
53
 
47
- return (
54
+ const content = (
48
55
  <AnimatePresence>
49
56
  {isOpen && (
50
57
  <div className={cn(
51
- "fixed inset-0 z-50 flex",
58
+ "fixed inset-0 flex",
52
59
  position === 'center' ? "items-center justify-center p-2 sm:p-4" : "items-end justify-center sm:items-center p-0 sm:p-4",
53
60
  size === 'full' ? 'p-0 sm:p-0' : ''
54
- )}>
61
+ )} style={{ zIndex: zIndex ?? 50 }}>
55
62
  <motion.div
56
63
  initial={{ opacity: 0 }}
57
64
  animate={{ opacity: 1 }}
@@ -73,7 +80,7 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
73
80
  >
74
81
  {/* Header */}
75
82
  <div className="flex items-center justify-between px-5 sm:px-8 py-4 border-b border-surface-0">
76
- <h3 className="text-[16px] sm:text-[18px] font-bold text-foreground-0 tracking-tight">{title}</h3>
83
+ <h3 className="text-[16px] sm:text-[18px] font-bold text-foreground-0 tracking-tight flex-1 min-w-0">{title}</h3>
77
84
  <button
78
85
  onClick={onClose}
79
86
  className="w-8 h-8 rounded-full flex items-center justify-center text-foreground-disabled hover:bg-surface-0 hover:text-foreground-0 transition-all"
@@ -92,7 +99,7 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
92
99
 
93
100
  {/* Footer */}
94
101
  {footer && (
95
- <div className="px-5 sm:px-8 pb-5 sm:pb-7 flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3">
102
+ <div className="px-5 sm:px-8 pb-5 flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-3">
96
103
  {footer}
97
104
  </div>
98
105
  )}
@@ -101,6 +108,11 @@ export const Modal = ({ isOpen, onClose, title, children, className, footer, bac
101
108
  )}
102
109
  </AnimatePresence>
103
110
  );
111
+
112
+ // Always render via a portal into <body> so the modal escapes any parent
113
+ // stacking context created by Framer Motion transforms or CSS transforms.
114
+ if (!mounted) return null;
115
+ return createPortal(content, document.body);
104
116
  };
105
117
 
106
118
  export const ModalFooter = ({ children, className }: { children: React.ReactNode; className?: string }) => {
@@ -12,8 +12,8 @@ export interface PopConfirmProps {
12
12
  /** Element that triggers the popover (e.g. Button) */
13
13
  trigger: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }>;
14
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;
15
+ /** Called when user confirms. Call callback() to close. Returning a Promise automatically handles loading state. */
16
+ onConfirm: (callback: () => void, setLoading: (loading: boolean) => void) => void | Promise<any>;
17
17
 
18
18
  /** Called when user cancels. Call callback() to close. */
19
19
  onCancel: (callback: () => void) => void;
@@ -76,8 +76,19 @@ export const PopConfirm = ({
76
76
  setIsLoading(false);
77
77
  }, []);
78
78
 
79
- const handleConfirm = useCallback(() => {
80
- onConfirm(close, setIsLoading);
79
+ const handleConfirm = useCallback(async () => {
80
+ setIsLoading(true);
81
+ try {
82
+ const result = onConfirm(close, setIsLoading);
83
+ if (result instanceof Promise) {
84
+ await result;
85
+ // Automatically close if the promise resolves successfully
86
+ close();
87
+ }
88
+ } catch (e) {
89
+ console.error(e);
90
+ setIsLoading(false);
91
+ }
81
92
  }, [onConfirm, close]);
82
93
 
83
94
  const handleCancel = useCallback(() => {