@papernote/ui 1.7.7 → 1.8.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/dist/components/Badge.d.ts +3 -1
- package/dist/components/Badge.d.ts.map +1 -1
- package/dist/components/BottomSheet.d.ts +72 -8
- package/dist/components/BottomSheet.d.ts.map +1 -1
- package/dist/components/CompactStat.d.ts +52 -0
- package/dist/components/CompactStat.d.ts.map +1 -0
- package/dist/components/HorizontalScroll.d.ts +43 -0
- package/dist/components/HorizontalScroll.d.ts.map +1 -0
- package/dist/components/NotificationBanner.d.ts +53 -0
- package/dist/components/NotificationBanner.d.ts.map +1 -0
- package/dist/components/Progress.d.ts +2 -2
- package/dist/components/Progress.d.ts.map +1 -1
- package/dist/components/PullToRefresh.d.ts +23 -71
- package/dist/components/PullToRefresh.d.ts.map +1 -1
- package/dist/components/Stack.d.ts +2 -1
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/SwipeableCard.d.ts +65 -0
- package/dist/components/SwipeableCard.d.ts.map +1 -0
- package/dist/components/Text.d.ts +9 -2
- package/dist/components/Text.d.ts.map +1 -1
- package/dist/components/index.d.ts +11 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +317 -86
- package/dist/index.esm.js +932 -253
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +937 -252
- package/dist/index.js.map +1 -1
- package/dist/styles.css +178 -8
- package/package.json +1 -1
- package/src/components/Badge.tsx +13 -2
- package/src/components/BottomSheet.tsx +227 -98
- package/src/components/Card.tsx +1 -1
- package/src/components/CompactStat.tsx +150 -0
- package/src/components/HorizontalScroll.tsx +275 -0
- package/src/components/NotificationBanner.tsx +238 -0
- package/src/components/Progress.tsx +6 -3
- package/src/components/PullToRefresh.tsx +158 -196
- package/src/components/Stack.tsx +4 -1
- package/src/components/SwipeableCard.tsx +347 -0
- package/src/components/Text.tsx +45 -3
- package/src/components/index.ts +16 -3
- package/src/styles/index.css +32 -0
|
@@ -1,54 +1,112 @@
|
|
|
1
|
-
import { useEffect, useRef, useState,
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import { X } from 'lucide-react';
|
|
3
4
|
|
|
4
5
|
export interface BottomSheetProps {
|
|
5
|
-
|
|
6
|
+
/** Whether the bottom sheet is open (alias: isOpen) */
|
|
7
|
+
open?: boolean;
|
|
8
|
+
/** Whether the bottom sheet is open (alias for open, for Modal compatibility) */
|
|
9
|
+
isOpen?: boolean;
|
|
10
|
+
/** Callback when the sheet should close */
|
|
6
11
|
onClose: () => void;
|
|
7
|
-
|
|
12
|
+
/** Content of the bottom sheet */
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
/** Title displayed in header (if provided, renders built-in header) */
|
|
8
15
|
title?: string;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
/** Height of the sheet - 'auto' adjusts to content, 'sm'/'md'/'lg'/'full' presets, or specify px/% */
|
|
17
|
+
height?: 'auto' | 'sm' | 'md' | 'lg' | 'full' | number | string;
|
|
18
|
+
/** Maximum height of the sheet */
|
|
19
|
+
maxHeight?: string;
|
|
20
|
+
/** Snap points for partial expansion (e.g., ['50%', '90%']) */
|
|
21
|
+
snapPoints?: string[];
|
|
22
|
+
/** Close when clicking overlay */
|
|
12
23
|
closeOnOverlayClick?: boolean;
|
|
24
|
+
/** Close when pressing Escape */
|
|
13
25
|
closeOnEscape?: boolean;
|
|
14
|
-
|
|
26
|
+
/** Show drag handle at top */
|
|
27
|
+
showHandle?: boolean;
|
|
28
|
+
/** Show close button in header (requires title) */
|
|
29
|
+
showCloseButton?: boolean;
|
|
30
|
+
/** Prevent body scroll when open */
|
|
31
|
+
preventScroll?: boolean;
|
|
32
|
+
/** Additional class name */
|
|
15
33
|
className?: string;
|
|
16
34
|
}
|
|
17
35
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
export interface BottomSheetHeaderProps {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BottomSheetContentProps {
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
24
45
|
|
|
25
|
-
export
|
|
46
|
+
export interface BottomSheetActionsProps {
|
|
47
|
+
children: React.ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* BottomSheet - Mobile-friendly modal that slides up from the bottom
|
|
53
|
+
*
|
|
54
|
+
* Designed for mobile contexts with touch-friendly interactions:
|
|
55
|
+
* - Drag handle for swipe-to-dismiss
|
|
56
|
+
* - Snap points for partial expansion
|
|
57
|
+
* - Sticky action area at thumb zone
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```tsx
|
|
61
|
+
* <BottomSheet open={isOpen} onClose={() => setIsOpen(false)}>
|
|
62
|
+
* <BottomSheetHeader>
|
|
63
|
+
* <Text weight="bold">Transaction Details</Text>
|
|
64
|
+
* </BottomSheetHeader>
|
|
65
|
+
* <BottomSheetContent>
|
|
66
|
+
* {content}
|
|
67
|
+
* </BottomSheetContent>
|
|
68
|
+
* <BottomSheetActions>
|
|
69
|
+
* <Button fullWidth>Approve</Button>
|
|
70
|
+
* </BottomSheetActions>
|
|
71
|
+
* </BottomSheet>
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function BottomSheet({
|
|
75
|
+
open,
|
|
26
76
|
isOpen,
|
|
27
77
|
onClose,
|
|
28
78
|
children,
|
|
29
79
|
title,
|
|
30
|
-
height = '
|
|
31
|
-
|
|
32
|
-
|
|
80
|
+
height = 'auto',
|
|
81
|
+
maxHeight = '90vh',
|
|
82
|
+
snapPoints,
|
|
33
83
|
closeOnOverlayClick = true,
|
|
34
84
|
closeOnEscape = true,
|
|
35
|
-
|
|
85
|
+
showHandle = true,
|
|
86
|
+
showCloseButton = true,
|
|
87
|
+
preventScroll = true,
|
|
36
88
|
className = '',
|
|
37
89
|
}: BottomSheetProps) {
|
|
38
|
-
|
|
90
|
+
// Support both 'open' and 'isOpen' props for flexibility
|
|
91
|
+
const isSheetOpen = open ?? isOpen ?? false;
|
|
92
|
+
|
|
93
|
+
// Height presets for convenience
|
|
94
|
+
const heightPresets: Record<string, string> = {
|
|
95
|
+
sm: '40vh',
|
|
96
|
+
md: '60vh',
|
|
97
|
+
lg: '80vh',
|
|
98
|
+
full: '100vh',
|
|
99
|
+
};
|
|
100
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
39
101
|
const [isDragging, setIsDragging] = useState(false);
|
|
40
102
|
const [dragOffset, setDragOffset] = useState(0);
|
|
41
|
-
const [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
: height
|
|
45
|
-
);
|
|
46
|
-
const sheetRef = useRef<HTMLDivElement>(null);
|
|
47
|
-
const startYRef = useRef<number>(0);
|
|
103
|
+
const [currentSnapIndex, setCurrentSnapIndex] = useState(snapPoints?.length ? snapPoints.length - 1 : 0);
|
|
104
|
+
const startY = useRef(0);
|
|
105
|
+
const startOffset = useRef(0);
|
|
48
106
|
|
|
49
|
-
//
|
|
107
|
+
// Handle escape key
|
|
50
108
|
useEffect(() => {
|
|
51
|
-
if (!
|
|
109
|
+
if (!isSheetOpen || !closeOnEscape) return;
|
|
52
110
|
|
|
53
111
|
const handleEscape = (e: KeyboardEvent) => {
|
|
54
112
|
if (e.key === 'Escape') {
|
|
@@ -58,130 +116,164 @@ export default function BottomSheet({
|
|
|
58
116
|
|
|
59
117
|
document.addEventListener('keydown', handleEscape);
|
|
60
118
|
return () => document.removeEventListener('keydown', handleEscape);
|
|
61
|
-
}, [
|
|
119
|
+
}, [open, closeOnEscape, onClose]);
|
|
62
120
|
|
|
63
|
-
// Prevent body scroll
|
|
121
|
+
// Prevent body scroll
|
|
64
122
|
useEffect(() => {
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
123
|
+
if (!isSheetOpen || !preventScroll) return;
|
|
124
|
+
|
|
125
|
+
const originalOverflow = document.body.style.overflow;
|
|
126
|
+
document.body.style.overflow = 'hidden';
|
|
70
127
|
|
|
71
128
|
return () => {
|
|
72
|
-
document.body.style.overflow =
|
|
129
|
+
document.body.style.overflow = originalOverflow;
|
|
73
130
|
};
|
|
74
|
-
}, [
|
|
131
|
+
}, [open, preventScroll]);
|
|
75
132
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
onClose();
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
const handleDragStart = (e: React.TouchEvent | React.MouseEvent) => {
|
|
133
|
+
// Handle drag start
|
|
134
|
+
const handleDragStart = useCallback((clientY: number) => {
|
|
83
135
|
setIsDragging(true);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
136
|
+
startY.current = clientY;
|
|
137
|
+
startOffset.current = dragOffset;
|
|
138
|
+
}, [dragOffset]);
|
|
87
139
|
|
|
88
|
-
|
|
140
|
+
// Handle drag move
|
|
141
|
+
const handleDragMove = useCallback((clientY: number) => {
|
|
89
142
|
if (!isDragging) return;
|
|
143
|
+
|
|
144
|
+
const delta = clientY - startY.current;
|
|
145
|
+
const newOffset = Math.max(0, startOffset.current + delta);
|
|
146
|
+
setDragOffset(newOffset);
|
|
147
|
+
}, [isDragging]);
|
|
90
148
|
|
|
91
|
-
|
|
92
|
-
|
|
149
|
+
// Handle drag end
|
|
150
|
+
const handleDragEnd = useCallback(() => {
|
|
151
|
+
if (!isDragging) return;
|
|
152
|
+
setIsDragging(false);
|
|
93
153
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
154
|
+
const threshold = 100; // pixels to trigger close
|
|
155
|
+
|
|
156
|
+
if (dragOffset > threshold) {
|
|
157
|
+
// If we have snap points, snap to next lower point or close
|
|
158
|
+
if (snapPoints && currentSnapIndex > 0) {
|
|
159
|
+
setCurrentSnapIndex(currentSnapIndex - 1);
|
|
160
|
+
setDragOffset(0);
|
|
161
|
+
} else {
|
|
162
|
+
onClose();
|
|
163
|
+
setDragOffset(0);
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Snap back
|
|
167
|
+
setDragOffset(0);
|
|
97
168
|
}
|
|
169
|
+
}, [isDragging, dragOffset, snapPoints, currentSnapIndex, onClose]);
|
|
170
|
+
|
|
171
|
+
// Touch event handlers
|
|
172
|
+
const handleTouchStart = (e: React.TouchEvent) => {
|
|
173
|
+
handleDragStart(e.touches[0].clientY);
|
|
98
174
|
};
|
|
99
175
|
|
|
100
|
-
const
|
|
101
|
-
|
|
176
|
+
const handleTouchMove = (e: React.TouchEvent) => {
|
|
177
|
+
handleDragMove(e.touches[0].clientY);
|
|
178
|
+
};
|
|
102
179
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
180
|
+
const handleTouchEnd = () => {
|
|
181
|
+
handleDragEnd();
|
|
182
|
+
};
|
|
107
183
|
|
|
108
|
-
|
|
184
|
+
// Mouse event handlers (for desktop testing)
|
|
185
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
186
|
+
handleDragStart(e.clientY);
|
|
109
187
|
};
|
|
110
188
|
|
|
111
189
|
useEffect(() => {
|
|
112
190
|
if (!isDragging) return;
|
|
113
191
|
|
|
114
|
-
const
|
|
115
|
-
|
|
192
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
193
|
+
handleDragMove(e.clientY);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleMouseUp = () => {
|
|
197
|
+
handleDragEnd();
|
|
198
|
+
};
|
|
116
199
|
|
|
117
|
-
document.addEventListener('
|
|
118
|
-
document.addEventListener('
|
|
119
|
-
document.addEventListener('touchend', handleEnd);
|
|
120
|
-
document.addEventListener('mouseup', handleEnd);
|
|
200
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
201
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
121
202
|
|
|
122
203
|
return () => {
|
|
123
|
-
document.removeEventListener('
|
|
124
|
-
document.removeEventListener('
|
|
125
|
-
document.removeEventListener('touchend', handleEnd);
|
|
126
|
-
document.removeEventListener('mouseup', handleEnd);
|
|
204
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
205
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
127
206
|
};
|
|
128
|
-
}, [isDragging,
|
|
207
|
+
}, [isDragging, handleDragMove, handleDragEnd]);
|
|
129
208
|
|
|
130
|
-
|
|
209
|
+
// Calculate height based on snap points or presets
|
|
210
|
+
const getSheetHeight = () => {
|
|
211
|
+
if (snapPoints && snapPoints[currentSnapIndex]) {
|
|
212
|
+
return snapPoints[currentSnapIndex];
|
|
213
|
+
}
|
|
214
|
+
if (typeof height === 'number') {
|
|
215
|
+
return `${height}px`;
|
|
216
|
+
}
|
|
217
|
+
// Check for preset heights
|
|
218
|
+
if (typeof height === 'string' && heightPresets[height]) {
|
|
219
|
+
return heightPresets[height];
|
|
220
|
+
}
|
|
221
|
+
return height;
|
|
222
|
+
};
|
|
131
223
|
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
>
|
|
224
|
+
if (!isSheetOpen) return null;
|
|
225
|
+
|
|
226
|
+
const sheetContent = (
|
|
227
|
+
<div className="fixed inset-0 z-50">
|
|
137
228
|
{/* Overlay */}
|
|
138
229
|
<div
|
|
139
230
|
className={`
|
|
140
231
|
absolute inset-0 bg-black/50 transition-opacity duration-300
|
|
141
|
-
${
|
|
232
|
+
${isSheetOpen ? 'opacity-100' : 'opacity-0'}
|
|
142
233
|
`}
|
|
234
|
+
onClick={closeOnOverlayClick ? onClose : undefined}
|
|
235
|
+
aria-hidden="true"
|
|
143
236
|
/>
|
|
144
237
|
|
|
145
238
|
{/* Sheet */}
|
|
146
239
|
<div
|
|
147
240
|
ref={sheetRef}
|
|
148
241
|
className={`
|
|
149
|
-
|
|
242
|
+
absolute bottom-0 left-0 right-0
|
|
243
|
+
bg-white rounded-t-2xl shadow-2xl
|
|
150
244
|
transition-transform duration-300 ease-out
|
|
151
|
-
${
|
|
245
|
+
${isDragging ? 'transition-none' : ''}
|
|
152
246
|
${className}
|
|
153
247
|
`}
|
|
154
248
|
style={{
|
|
155
|
-
height:
|
|
249
|
+
height: getSheetHeight(),
|
|
250
|
+
maxHeight,
|
|
156
251
|
transform: `translateY(${dragOffset}px)`,
|
|
157
252
|
}}
|
|
158
253
|
role="dialog"
|
|
159
254
|
aria-modal="true"
|
|
160
|
-
aria-labelledby={title ? titleId : undefined}
|
|
161
255
|
>
|
|
162
|
-
{/* Handle */}
|
|
256
|
+
{/* Drag Handle */}
|
|
163
257
|
{showHandle && (
|
|
164
258
|
<div
|
|
165
|
-
className="
|
|
166
|
-
onTouchStart={
|
|
167
|
-
|
|
259
|
+
className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing touch-none"
|
|
260
|
+
onTouchStart={handleTouchStart}
|
|
261
|
+
onTouchMove={handleTouchMove}
|
|
262
|
+
onTouchEnd={handleTouchEnd}
|
|
263
|
+
onMouseDown={handleMouseDown}
|
|
168
264
|
>
|
|
169
|
-
<div className="w-
|
|
265
|
+
<div className="w-10 h-1 bg-paper-300 rounded-full" />
|
|
170
266
|
</div>
|
|
171
267
|
)}
|
|
172
268
|
|
|
173
|
-
{/*
|
|
174
|
-
{
|
|
175
|
-
<div className="px-
|
|
176
|
-
{title
|
|
177
|
-
<h2 id={titleId} className="text-lg font-semibold text-ink-900">
|
|
178
|
-
{title}
|
|
179
|
-
</h2>
|
|
180
|
-
)}
|
|
269
|
+
{/* Built-in header with title (when title prop is provided) */}
|
|
270
|
+
{title && (
|
|
271
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-paper-200">
|
|
272
|
+
<h2 className="text-lg font-medium text-ink-900">{title}</h2>
|
|
181
273
|
{showCloseButton && (
|
|
182
274
|
<button
|
|
183
275
|
onClick={onClose}
|
|
184
|
-
className="text-ink-
|
|
276
|
+
className="p-1 rounded-full text-ink-500 hover:text-ink-700 hover:bg-paper-100 transition-colors"
|
|
185
277
|
aria-label="Close"
|
|
186
278
|
>
|
|
187
279
|
<X className="h-5 w-5" />
|
|
@@ -190,11 +282,48 @@ export default function BottomSheet({
|
|
|
190
282
|
</div>
|
|
191
283
|
)}
|
|
192
284
|
|
|
193
|
-
{/* Content */}
|
|
194
|
-
<div className="
|
|
285
|
+
{/* Content wrapper with flex layout */}
|
|
286
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
195
287
|
{children}
|
|
196
288
|
</div>
|
|
197
289
|
</div>
|
|
198
290
|
</div>
|
|
199
291
|
);
|
|
292
|
+
|
|
293
|
+
return createPortal(sheetContent, document.body);
|
|
200
294
|
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* BottomSheetHeader - Header section with title and optional close button
|
|
298
|
+
*/
|
|
299
|
+
export function BottomSheetHeader({ children, className = '' }: BottomSheetHeaderProps) {
|
|
300
|
+
return (
|
|
301
|
+
<div className={`flex items-center justify-between px-4 py-3 border-b border-paper-200 ${className}`}>
|
|
302
|
+
{children}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* BottomSheetContent - Scrollable content area
|
|
309
|
+
*/
|
|
310
|
+
export function BottomSheetContent({ children, className = '' }: BottomSheetContentProps) {
|
|
311
|
+
return (
|
|
312
|
+
<div className={`flex-1 overflow-y-auto px-4 py-4 ${className}`}>
|
|
313
|
+
{children}
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* BottomSheetActions - Sticky footer for action buttons (thumb zone)
|
|
320
|
+
*/
|
|
321
|
+
export function BottomSheetActions({ children, className = '' }: BottomSheetActionsProps) {
|
|
322
|
+
return (
|
|
323
|
+
<div className={`flex flex-col gap-2 px-4 py-4 border-t border-paper-200 bg-white ${className}`}>
|
|
324
|
+
{children}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export default BottomSheet;
|
package/src/components/Card.tsx
CHANGED
|
@@ -76,7 +76,7 @@ const Card = forwardRef<HTMLDivElement, CardProps>(({
|
|
|
76
76
|
|
|
77
77
|
const variantStyles = {
|
|
78
78
|
default: 'rounded-xl shadow-lg p-8',
|
|
79
|
-
compact: 'rounded-lg shadow-md p-
|
|
79
|
+
compact: 'rounded-lg shadow-md p-3', // 12px padding for mobile-density layouts
|
|
80
80
|
flat: 'rounded-lg p-5',
|
|
81
81
|
};
|
|
82
82
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
|
|
2
|
+
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
export interface CompactStatTrend {
|
|
5
|
+
/** Direction of the trend */
|
|
6
|
+
direction: 'up' | 'down' | 'neutral';
|
|
7
|
+
/** Value to display (e.g., "+$1,247" or "+12%") */
|
|
8
|
+
value: string;
|
|
9
|
+
/** Color override (defaults based on direction) */
|
|
10
|
+
color?: 'success' | 'error' | 'warning' | 'neutral';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CompactStatProps {
|
|
14
|
+
/** The main value to display */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Label describing the stat */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Optional trend indicator */
|
|
19
|
+
trend?: CompactStatTrend;
|
|
20
|
+
/** Size variant */
|
|
21
|
+
size?: 'sm' | 'md' | 'lg';
|
|
22
|
+
/** Text alignment */
|
|
23
|
+
align?: 'left' | 'center' | 'right';
|
|
24
|
+
/** Additional class name */
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* CompactStat - Single stat display optimized for mobile
|
|
30
|
+
*
|
|
31
|
+
* Designed for dashboard stats in 2-column mobile layouts:
|
|
32
|
+
* - Compact presentation with value, label, and optional trend
|
|
33
|
+
* - Responsive sizing
|
|
34
|
+
* - Trend indicators with color coding
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* <Grid columns={2} gap="sm">
|
|
39
|
+
* <CompactStat
|
|
40
|
+
* value="$62,329"
|
|
41
|
+
* label="Net Worth"
|
|
42
|
+
* trend={{
|
|
43
|
+
* direction: 'up',
|
|
44
|
+
* value: '+$1,247',
|
|
45
|
+
* color: 'success'
|
|
46
|
+
* }}
|
|
47
|
+
* />
|
|
48
|
+
* <CompactStat
|
|
49
|
+
* value="$4,521"
|
|
50
|
+
* label="Monthly Income"
|
|
51
|
+
* />
|
|
52
|
+
* </Grid>
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function CompactStat({
|
|
56
|
+
value,
|
|
57
|
+
label,
|
|
58
|
+
trend,
|
|
59
|
+
size = 'md',
|
|
60
|
+
align = 'left',
|
|
61
|
+
className = '',
|
|
62
|
+
}: CompactStatProps) {
|
|
63
|
+
// Size classes
|
|
64
|
+
const sizeClasses = {
|
|
65
|
+
sm: {
|
|
66
|
+
value: 'text-lg font-semibold',
|
|
67
|
+
label: 'text-xs',
|
|
68
|
+
trend: 'text-xs',
|
|
69
|
+
icon: 'h-3 w-3',
|
|
70
|
+
},
|
|
71
|
+
md: {
|
|
72
|
+
value: 'text-xl font-semibold',
|
|
73
|
+
label: 'text-sm',
|
|
74
|
+
trend: 'text-xs',
|
|
75
|
+
icon: 'h-3.5 w-3.5',
|
|
76
|
+
},
|
|
77
|
+
lg: {
|
|
78
|
+
value: 'text-2xl font-bold',
|
|
79
|
+
label: 'text-sm',
|
|
80
|
+
trend: 'text-sm',
|
|
81
|
+
icon: 'h-4 w-4',
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Alignment classes
|
|
86
|
+
const alignClasses = {
|
|
87
|
+
left: 'text-left',
|
|
88
|
+
center: 'text-center',
|
|
89
|
+
right: 'text-right',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Trend color classes
|
|
93
|
+
const getTrendColor = (trend: CompactStatTrend) => {
|
|
94
|
+
if (trend.color) {
|
|
95
|
+
const colorMap = {
|
|
96
|
+
success: 'text-success-600',
|
|
97
|
+
error: 'text-error-600',
|
|
98
|
+
warning: 'text-warning-600',
|
|
99
|
+
neutral: 'text-ink-500',
|
|
100
|
+
};
|
|
101
|
+
return colorMap[trend.color];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Default colors based on direction
|
|
105
|
+
const directionColors = {
|
|
106
|
+
up: 'text-success-600',
|
|
107
|
+
down: 'text-error-600',
|
|
108
|
+
neutral: 'text-ink-500',
|
|
109
|
+
};
|
|
110
|
+
return directionColors[trend.direction];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Trend icons
|
|
114
|
+
const TrendIcon = trend ? {
|
|
115
|
+
up: TrendingUp,
|
|
116
|
+
down: TrendingDown,
|
|
117
|
+
neutral: Minus,
|
|
118
|
+
}[trend.direction] : null;
|
|
119
|
+
|
|
120
|
+
const sizes = sizeClasses[size];
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={`${alignClasses[align]} ${className}`}>
|
|
124
|
+
{/* Main value */}
|
|
125
|
+
<div className={`${sizes.value} text-ink-900 tracking-tight`}>
|
|
126
|
+
{value}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Label */}
|
|
130
|
+
<div className={`${sizes.label} text-ink-500 mt-0.5`}>
|
|
131
|
+
{label}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Trend indicator */}
|
|
135
|
+
{trend && (
|
|
136
|
+
<div className={`
|
|
137
|
+
flex items-center gap-1 mt-1
|
|
138
|
+
${align === 'center' ? 'justify-center' : ''}
|
|
139
|
+
${align === 'right' ? 'justify-end' : ''}
|
|
140
|
+
${sizes.trend} ${getTrendColor(trend)}
|
|
141
|
+
`}>
|
|
142
|
+
{TrendIcon && <TrendIcon className={sizes.icon} />}
|
|
143
|
+
<span>{trend.value}</span>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export default CompactStat;
|