@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.
Files changed (42) hide show
  1. package/dist/components/Badge.d.ts +3 -1
  2. package/dist/components/Badge.d.ts.map +1 -1
  3. package/dist/components/BottomSheet.d.ts +72 -8
  4. package/dist/components/BottomSheet.d.ts.map +1 -1
  5. package/dist/components/CompactStat.d.ts +52 -0
  6. package/dist/components/CompactStat.d.ts.map +1 -0
  7. package/dist/components/HorizontalScroll.d.ts +43 -0
  8. package/dist/components/HorizontalScroll.d.ts.map +1 -0
  9. package/dist/components/NotificationBanner.d.ts +53 -0
  10. package/dist/components/NotificationBanner.d.ts.map +1 -0
  11. package/dist/components/Progress.d.ts +2 -2
  12. package/dist/components/Progress.d.ts.map +1 -1
  13. package/dist/components/PullToRefresh.d.ts +23 -71
  14. package/dist/components/PullToRefresh.d.ts.map +1 -1
  15. package/dist/components/Stack.d.ts +2 -1
  16. package/dist/components/Stack.d.ts.map +1 -1
  17. package/dist/components/SwipeableCard.d.ts +65 -0
  18. package/dist/components/SwipeableCard.d.ts.map +1 -0
  19. package/dist/components/Text.d.ts +9 -2
  20. package/dist/components/Text.d.ts.map +1 -1
  21. package/dist/components/index.d.ts +11 -3
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/index.d.ts +317 -86
  24. package/dist/index.esm.js +932 -253
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/index.js +937 -252
  27. package/dist/index.js.map +1 -1
  28. package/dist/styles.css +178 -8
  29. package/package.json +1 -1
  30. package/src/components/Badge.tsx +13 -2
  31. package/src/components/BottomSheet.tsx +227 -98
  32. package/src/components/Card.tsx +1 -1
  33. package/src/components/CompactStat.tsx +150 -0
  34. package/src/components/HorizontalScroll.tsx +275 -0
  35. package/src/components/NotificationBanner.tsx +238 -0
  36. package/src/components/Progress.tsx +6 -3
  37. package/src/components/PullToRefresh.tsx +158 -196
  38. package/src/components/Stack.tsx +4 -1
  39. package/src/components/SwipeableCard.tsx +347 -0
  40. package/src/components/Text.tsx +45 -3
  41. package/src/components/index.ts +16 -3
  42. package/src/styles/index.css +32 -0
@@ -1,54 +1,112 @@
1
- import { useEffect, useRef, useState, ReactNode, useId } from 'react';
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
- isOpen: boolean;
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
- children: ReactNode;
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
- height?: 'sm' | 'md' | 'lg' | 'full' | string;
10
- showHandle?: boolean;
11
- showCloseButton?: boolean;
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
- snapPoints?: number[]; // Snap heights as percentages (e.g., [25, 50, 90])
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
- const heightPresets = {
19
- sm: '33vh',
20
- md: '50vh',
21
- lg: '75vh',
22
- full: '90vh',
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 default function BottomSheet({
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 = 'md',
31
- showHandle = true,
32
- showCloseButton = true,
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
- const titleId = useId();
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 [currentHeight] = useState<string>(
42
- typeof height === 'string' && height in heightPresets
43
- ? heightPresets[height as keyof typeof heightPresets]
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
- // Close on Escape
107
+ // Handle escape key
50
108
  useEffect(() => {
51
- if (!isOpen || !closeOnEscape) return;
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
- }, [isOpen, closeOnEscape, onClose]);
119
+ }, [open, closeOnEscape, onClose]);
62
120
 
63
- // Prevent body scroll when open
121
+ // Prevent body scroll
64
122
  useEffect(() => {
65
- if (isOpen) {
66
- document.body.style.overflow = 'hidden';
67
- } else {
68
- document.body.style.overflow = '';
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
- }, [isOpen]);
131
+ }, [open, preventScroll]);
75
132
 
76
- const handleOverlayClick = (e: React.MouseEvent) => {
77
- if (closeOnOverlayClick && e.target === e.currentTarget) {
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
- const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
85
- startYRef.current = clientY;
86
- };
136
+ startY.current = clientY;
137
+ startOffset.current = dragOffset;
138
+ }, [dragOffset]);
87
139
 
88
- const handleDragMove = (e: TouchEvent | MouseEvent) => {
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
- const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
92
- const offset = clientY - startYRef.current;
149
+ // Handle drag end
150
+ const handleDragEnd = useCallback(() => {
151
+ if (!isDragging) return;
152
+ setIsDragging(false);
93
153
 
94
- // Only allow dragging down
95
- if (offset > 0) {
96
- setDragOffset(offset);
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 handleDragEnd = () => {
101
- setIsDragging(false);
176
+ const handleTouchMove = (e: React.TouchEvent) => {
177
+ handleDragMove(e.touches[0].clientY);
178
+ };
102
179
 
103
- // Close if dragged down more than 150px
104
- if (dragOffset > 150) {
105
- onClose();
106
- }
180
+ const handleTouchEnd = () => {
181
+ handleDragEnd();
182
+ };
107
183
 
108
- setDragOffset(0);
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 handleMove = (e: TouchEvent | MouseEvent) => handleDragMove(e);
115
- const handleEnd = () => handleDragEnd();
192
+ const handleMouseMove = (e: MouseEvent) => {
193
+ handleDragMove(e.clientY);
194
+ };
195
+
196
+ const handleMouseUp = () => {
197
+ handleDragEnd();
198
+ };
116
199
 
117
- document.addEventListener('touchmove', handleMove);
118
- document.addEventListener('mousemove', handleMove);
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('touchmove', handleMove);
124
- document.removeEventListener('mousemove', handleMove);
125
- document.removeEventListener('touchend', handleEnd);
126
- document.removeEventListener('mouseup', handleEnd);
204
+ document.removeEventListener('mousemove', handleMouseMove);
205
+ document.removeEventListener('mouseup', handleMouseUp);
127
206
  };
128
- }, [isDragging, dragOffset]);
207
+ }, [isDragging, handleDragMove, handleDragEnd]);
129
208
 
130
- if (!isOpen) return null;
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
- <div
134
- className="fixed inset-0 z-50 flex items-end"
135
- onClick={handleOverlayClick}
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
- ${isOpen ? 'opacity-100' : 'opacity-0'}
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
- relative w-full bg-white rounded-t-2xl shadow-2xl
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
- ${isOpen ? 'translate-y-0' : 'translate-y-full'}
245
+ ${isDragging ? 'transition-none' : ''}
152
246
  ${className}
153
247
  `}
154
248
  style={{
155
- height: currentHeight,
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="py-3 cursor-grab active:cursor-grabbing"
166
- onTouchStart={handleDragStart}
167
- onMouseDown={handleDragStart}
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-12 h-1.5 bg-ink-300 rounded-full mx-auto" />
265
+ <div className="w-10 h-1 bg-paper-300 rounded-full" />
170
266
  </div>
171
267
  )}
172
268
 
173
- {/* Header */}
174
- {(title || showCloseButton) && (
175
- <div className="px-6 py-4 border-b border-ink-200 flex items-center justify-between">
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-400 hover:text-ink-600 transition-colors ml-auto"
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="overflow-y-auto flex-1 p-6">
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;
@@ -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-5',
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;