@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
@@ -0,0 +1,275 @@
1
+ import React, { useRef, useState, useEffect, useCallback } from 'react';
2
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
3
+
4
+ type GapSize = 'none' | 'sm' | 'md' | 'lg' | number;
5
+
6
+ export interface HorizontalScrollProps {
7
+ /** Items to display in horizontal scroll */
8
+ children: React.ReactNode;
9
+ /** Gap between items */
10
+ gap?: GapSize;
11
+ /** Pixels of next item visible as hint that more content exists */
12
+ peekAmount?: number;
13
+ /** Show dot indicators below */
14
+ showIndicators?: boolean;
15
+ /** Snap scroll to item boundaries */
16
+ snapToItem?: boolean;
17
+ /** When to show navigation arrows */
18
+ showArrows?: 'hover' | 'always' | 'never';
19
+ /** Scroll behavior */
20
+ scrollBehavior?: 'smooth' | 'auto';
21
+ /** Additional class name for container */
22
+ className?: string;
23
+ /** Additional class name for scroll container */
24
+ scrollClassName?: string;
25
+ }
26
+
27
+ /**
28
+ * HorizontalScroll - Horizontally scrollable container with peek indicators
29
+ *
30
+ * Designed for mobile carousels of cards with:
31
+ * - Touch-friendly momentum scrolling
32
+ * - Peek hint showing more items exist
33
+ * - Optional snap scrolling
34
+ * - Navigation arrows for desktop
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * <HorizontalScroll gap="md" peekAmount={24} showIndicators>
39
+ * <Card>Bill 1</Card>
40
+ * <Card>Bill 2</Card>
41
+ * <Card>Bill 3</Card>
42
+ * </HorizontalScroll>
43
+ * ```
44
+ */
45
+ export function HorizontalScroll({
46
+ children,
47
+ gap = 'md',
48
+ peekAmount = 24,
49
+ showIndicators = false,
50
+ snapToItem = true,
51
+ showArrows = 'hover',
52
+ scrollBehavior = 'smooth',
53
+ className = '',
54
+ scrollClassName = '',
55
+ }: HorizontalScrollProps) {
56
+ const scrollRef = useRef<HTMLDivElement>(null);
57
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
58
+ const [canScrollRight, setCanScrollRight] = useState(false);
59
+ const [activeIndex, setActiveIndex] = useState(0);
60
+ const [itemCount, setItemCount] = useState(0);
61
+ const [isHovered, setIsHovered] = useState(false);
62
+
63
+ // Gap classes
64
+ const gapClasses: Record<string, string> = {
65
+ none: 'gap-0',
66
+ sm: 'gap-2',
67
+ md: 'gap-4',
68
+ lg: 'gap-6',
69
+ };
70
+
71
+ const gapStyle = typeof gap === 'number' ? { gap: `${gap}px` } : {};
72
+ const gapClass = typeof gap === 'string' ? gapClasses[gap] : '';
73
+
74
+ // Check scroll position and update state
75
+ const checkScrollPosition = useCallback(() => {
76
+ const container = scrollRef.current;
77
+ if (!container) return;
78
+
79
+ const { scrollLeft, scrollWidth, clientWidth } = container;
80
+
81
+ setCanScrollLeft(scrollLeft > 0);
82
+ setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
83
+
84
+ // Calculate active index based on scroll position
85
+ if (showIndicators && container.children.length > 0) {
86
+ const children = Array.from(container.children) as HTMLElement[];
87
+ const containerRect = container.getBoundingClientRect();
88
+ const containerCenter = containerRect.left + containerRect.width / 2;
89
+
90
+ let closestIndex = 0;
91
+ let closestDistance = Infinity;
92
+
93
+ children.forEach((child, index) => {
94
+ const childRect = child.getBoundingClientRect();
95
+ const childCenter = childRect.left + childRect.width / 2;
96
+ const distance = Math.abs(childCenter - containerCenter);
97
+
98
+ if (distance < closestDistance) {
99
+ closestDistance = distance;
100
+ closestIndex = index;
101
+ }
102
+ });
103
+
104
+ setActiveIndex(closestIndex);
105
+ }
106
+ }, [showIndicators]);
107
+
108
+ // Initialize and handle resize
109
+ useEffect(() => {
110
+ const container = scrollRef.current;
111
+ if (!container) return;
112
+
113
+ setItemCount(React.Children.count(children));
114
+ checkScrollPosition();
115
+
116
+ const resizeObserver = new ResizeObserver(() => {
117
+ checkScrollPosition();
118
+ });
119
+
120
+ resizeObserver.observe(container);
121
+
122
+ return () => {
123
+ resizeObserver.disconnect();
124
+ };
125
+ }, [children, checkScrollPosition]);
126
+
127
+ // Handle scroll event
128
+ useEffect(() => {
129
+ const container = scrollRef.current;
130
+ if (!container) return;
131
+
132
+ const handleScroll = () => {
133
+ checkScrollPosition();
134
+ };
135
+
136
+ container.addEventListener('scroll', handleScroll, { passive: true });
137
+
138
+ return () => {
139
+ container.removeEventListener('scroll', handleScroll);
140
+ };
141
+ }, [checkScrollPosition]);
142
+
143
+ // Scroll by one item
144
+ const scrollByItem = (direction: 'left' | 'right') => {
145
+ const container = scrollRef.current;
146
+ if (!container) return;
147
+
148
+ const children = Array.from(container.children) as HTMLElement[];
149
+ if (children.length === 0) return;
150
+
151
+ const firstChild = children[0];
152
+ const itemWidth = firstChild.offsetWidth;
153
+ const gapValue = typeof gap === 'number' ? gap :
154
+ gap === 'sm' ? 8 : gap === 'md' ? 16 : gap === 'lg' ? 24 : 0;
155
+
156
+ const scrollAmount = itemWidth + gapValue;
157
+
158
+ container.scrollBy({
159
+ left: direction === 'left' ? -scrollAmount : scrollAmount,
160
+ behavior: scrollBehavior,
161
+ });
162
+ };
163
+
164
+ // Scroll to specific index
165
+ const scrollToIndex = (index: number) => {
166
+ const container = scrollRef.current;
167
+ if (!container) return;
168
+
169
+ const children = Array.from(container.children) as HTMLElement[];
170
+ if (index < 0 || index >= children.length) return;
171
+
172
+ const child = children[index];
173
+ child.scrollIntoView({
174
+ behavior: scrollBehavior,
175
+ block: 'nearest',
176
+ inline: 'center',
177
+ });
178
+ };
179
+
180
+ const showLeftArrow = showArrows === 'always' || (showArrows === 'hover' && isHovered);
181
+ const showRightArrow = showArrows === 'always' || (showArrows === 'hover' && isHovered);
182
+
183
+ return (
184
+ <div
185
+ className={`relative ${className}`}
186
+ onMouseEnter={() => setIsHovered(true)}
187
+ onMouseLeave={() => setIsHovered(false)}
188
+ >
189
+ {/* Left Arrow */}
190
+ {showLeftArrow && canScrollLeft && (
191
+ <button
192
+ onClick={() => scrollByItem('left')}
193
+ className="
194
+ absolute left-0 top-1/2 -translate-y-1/2 z-10
195
+ w-10 h-10 flex items-center justify-center
196
+ bg-white/90 backdrop-blur-sm rounded-full shadow-lg
197
+ text-ink-600 hover:text-ink-900 hover:bg-white
198
+ transition-all duration-200
199
+ -ml-2
200
+ "
201
+ aria-label="Scroll left"
202
+ >
203
+ <ChevronLeft className="h-5 w-5" />
204
+ </button>
205
+ )}
206
+
207
+ {/* Scroll Container */}
208
+ <div
209
+ ref={scrollRef}
210
+ className={`
211
+ flex overflow-x-auto scrollbar-hide
212
+ ${gapClass}
213
+ ${snapToItem ? 'snap-x snap-mandatory' : ''}
214
+ ${scrollClassName}
215
+ `}
216
+ style={{
217
+ ...gapStyle,
218
+ paddingRight: peekAmount > 0 ? `${peekAmount}px` : undefined,
219
+ scrollPaddingLeft: '0px',
220
+ scrollPaddingRight: `${peekAmount}px`,
221
+ }}
222
+ >
223
+ {React.Children.map(children, (child, index) => (
224
+ <div
225
+ key={index}
226
+ className={`flex-shrink-0 ${snapToItem ? 'snap-start' : ''}`}
227
+ >
228
+ {child}
229
+ </div>
230
+ ))}
231
+ </div>
232
+
233
+ {/* Right Arrow */}
234
+ {showRightArrow && canScrollRight && (
235
+ <button
236
+ onClick={() => scrollByItem('right')}
237
+ className="
238
+ absolute right-0 top-1/2 -translate-y-1/2 z-10
239
+ w-10 h-10 flex items-center justify-center
240
+ bg-white/90 backdrop-blur-sm rounded-full shadow-lg
241
+ text-ink-600 hover:text-ink-900 hover:bg-white
242
+ transition-all duration-200
243
+ -mr-2
244
+ "
245
+ aria-label="Scroll right"
246
+ >
247
+ <ChevronRight className="h-5 w-5" />
248
+ </button>
249
+ )}
250
+
251
+ {/* Dot Indicators */}
252
+ {showIndicators && itemCount > 1 && (
253
+ <div className="flex justify-center gap-1.5 mt-3">
254
+ {Array.from({ length: itemCount }).map((_, index) => (
255
+ <button
256
+ key={index}
257
+ onClick={() => scrollToIndex(index)}
258
+ className={`
259
+ w-2 h-2 rounded-full transition-all duration-200
260
+ ${index === activeIndex
261
+ ? 'bg-accent-500 w-4'
262
+ : 'bg-paper-300 hover:bg-paper-400'
263
+ }
264
+ `}
265
+ aria-label={`Go to item ${index + 1}`}
266
+ aria-current={index === activeIndex ? 'true' : 'false'}
267
+ />
268
+ ))}
269
+ </div>
270
+ )}
271
+ </div>
272
+ );
273
+ }
274
+
275
+ export default HorizontalScroll;
@@ -0,0 +1,238 @@
1
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
2
+ import { X, Info, CheckCircle, AlertTriangle, AlertCircle } from 'lucide-react';
3
+
4
+ export interface NotificationBannerAction {
5
+ /** Button label */
6
+ label: string;
7
+ /** Click handler */
8
+ onClick: () => void;
9
+ }
10
+
11
+ export interface NotificationBannerProps {
12
+ /** Banner variant determining color scheme */
13
+ variant?: 'info' | 'success' | 'warning' | 'error';
14
+ /** Custom icon (defaults based on variant) */
15
+ icon?: React.ReactNode;
16
+ /** Primary message/title */
17
+ title: string;
18
+ /** Optional secondary description text */
19
+ description?: string;
20
+ /** Optional action button */
21
+ action?: NotificationBannerAction;
22
+ /** Callback when dismissed - if provided, shows dismiss button */
23
+ onDismiss?: () => void;
24
+ /** Can be swiped away on mobile */
25
+ dismissible?: boolean;
26
+ /** Stick to top of container on scroll */
27
+ sticky?: boolean;
28
+ /** Additional class name */
29
+ className?: string;
30
+ }
31
+
32
+ /**
33
+ * NotificationBanner - Dismissible banner for important alerts
34
+ *
35
+ * Displays at top of screen for alerts that need attention but aren't blocking:
36
+ * - Money Found alerts
37
+ * - System messages
38
+ * - Promotional info
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * <NotificationBanner
43
+ * variant="warning"
44
+ * icon={<DollarSign />}
45
+ * title="Found $33.98 in potential savings"
46
+ * description="Tap to review"
47
+ * action={{
48
+ * label: "Review",
49
+ * onClick: handleReview
50
+ * }}
51
+ * onDismiss={() => setShowBanner(false)}
52
+ * />
53
+ * ```
54
+ */
55
+ export function NotificationBanner({
56
+ variant = 'info',
57
+ icon,
58
+ title,
59
+ description,
60
+ action,
61
+ onDismiss,
62
+ dismissible = true,
63
+ sticky = false,
64
+ className = '',
65
+ }: NotificationBannerProps) {
66
+ const bannerRef = useRef<HTMLDivElement>(null);
67
+ const [isDragging, setIsDragging] = useState(false);
68
+ const [offsetX, setOffsetX] = useState(0);
69
+ const [isDismissed, setIsDismissed] = useState(false);
70
+ const startX = useRef(0);
71
+
72
+ // Default icons based on variant
73
+ const defaultIcons: Record<typeof variant, React.ReactNode> = {
74
+ info: <Info className="h-5 w-5" />,
75
+ success: <CheckCircle className="h-5 w-5" />,
76
+ warning: <AlertTriangle className="h-5 w-5" />,
77
+ error: <AlertCircle className="h-5 w-5" />,
78
+ };
79
+
80
+ // Color classes
81
+ const variantClasses: Record<typeof variant, string> = {
82
+ info: 'bg-gradient-to-r from-primary-50 to-primary-100 border-primary-200 text-primary-900',
83
+ success: 'bg-gradient-to-r from-success-50 to-success-100 border-success-200 text-success-900',
84
+ warning: 'bg-gradient-to-r from-warning-50 to-warning-100 border-warning-200 text-warning-900',
85
+ error: 'bg-gradient-to-r from-error-50 to-error-100 border-error-200 text-error-900',
86
+ };
87
+
88
+ const iconColorClasses: Record<typeof variant, string> = {
89
+ info: 'text-primary-600',
90
+ success: 'text-success-600',
91
+ warning: 'text-warning-600',
92
+ error: 'text-error-600',
93
+ };
94
+
95
+ const buttonClasses: Record<typeof variant, string> = {
96
+ info: 'bg-primary-600 hover:bg-primary-700 text-white',
97
+ success: 'bg-success-600 hover:bg-success-700 text-white',
98
+ warning: 'bg-warning-600 hover:bg-warning-700 text-white',
99
+ error: 'bg-error-600 hover:bg-error-700 text-white',
100
+ };
101
+
102
+ // Handle swipe dismiss
103
+ const handleDragStart = useCallback((clientX: number) => {
104
+ if (!dismissible) return;
105
+ setIsDragging(true);
106
+ startX.current = clientX;
107
+ }, [dismissible]);
108
+
109
+ const handleDragMove = useCallback((clientX: number) => {
110
+ if (!isDragging) return;
111
+ const delta = clientX - startX.current;
112
+ setOffsetX(delta);
113
+ }, [isDragging]);
114
+
115
+ const handleDragEnd = useCallback(() => {
116
+ if (!isDragging) return;
117
+ setIsDragging(false);
118
+
119
+ const threshold = 100;
120
+ if (Math.abs(offsetX) > threshold) {
121
+ // Animate out
122
+ setOffsetX(offsetX > 0 ? window.innerWidth : -window.innerWidth);
123
+ setIsDismissed(true);
124
+ setTimeout(() => {
125
+ onDismiss?.();
126
+ }, 200);
127
+ } else {
128
+ // Snap back
129
+ setOffsetX(0);
130
+ }
131
+ }, [isDragging, offsetX, onDismiss]);
132
+
133
+ // Touch handlers
134
+ const handleTouchStart = (e: React.TouchEvent) => {
135
+ handleDragStart(e.touches[0].clientX);
136
+ };
137
+
138
+ const handleTouchMove = (e: React.TouchEvent) => {
139
+ handleDragMove(e.touches[0].clientX);
140
+ };
141
+
142
+ const handleTouchEnd = () => {
143
+ handleDragEnd();
144
+ };
145
+
146
+ // Mouse handlers for desktop testing
147
+ const handleMouseDown = (e: React.MouseEvent) => {
148
+ if (dismissible) {
149
+ handleDragStart(e.clientX);
150
+ }
151
+ };
152
+
153
+ useEffect(() => {
154
+ if (!isDragging) return;
155
+
156
+ const handleMouseMove = (e: MouseEvent) => {
157
+ handleDragMove(e.clientX);
158
+ };
159
+
160
+ const handleMouseUp = () => {
161
+ handleDragEnd();
162
+ };
163
+
164
+ document.addEventListener('mousemove', handleMouseMove);
165
+ document.addEventListener('mouseup', handleMouseUp);
166
+
167
+ return () => {
168
+ document.removeEventListener('mousemove', handleMouseMove);
169
+ document.removeEventListener('mouseup', handleMouseUp);
170
+ };
171
+ }, [isDragging, handleDragMove, handleDragEnd]);
172
+
173
+ if (isDismissed) return null;
174
+
175
+ return (
176
+ <div
177
+ ref={bannerRef}
178
+ className={`
179
+ w-full border-b
180
+ ${variantClasses[variant]}
181
+ ${sticky ? 'sticky top-0 z-40' : ''}
182
+ ${isDragging ? '' : 'transition-transform duration-200 ease-out'}
183
+ ${className}
184
+ `}
185
+ style={{
186
+ transform: `translateX(${offsetX}px)`,
187
+ opacity: Math.max(0, 1 - Math.abs(offsetX) / 200),
188
+ }}
189
+ onTouchStart={handleTouchStart}
190
+ onTouchMove={handleTouchMove}
191
+ onTouchEnd={handleTouchEnd}
192
+ onMouseDown={handleMouseDown}
193
+ role="alert"
194
+ >
195
+ <div className="flex items-center gap-3 px-4 py-3">
196
+ {/* Icon */}
197
+ <div className={`flex-shrink-0 ${iconColorClasses[variant]}`}>
198
+ {icon || defaultIcons[variant]}
199
+ </div>
200
+
201
+ {/* Text content */}
202
+ <div className="flex-1 min-w-0">
203
+ <p className="text-sm font-medium truncate">{title}</p>
204
+ {description && (
205
+ <p className="text-xs opacity-80 truncate">{description}</p>
206
+ )}
207
+ </div>
208
+
209
+ {/* Action button */}
210
+ {action && (
211
+ <button
212
+ onClick={action.onClick}
213
+ className={`
214
+ flex-shrink-0 px-3 py-1.5 text-xs font-medium rounded-md
215
+ transition-colors duration-200
216
+ ${buttonClasses[variant]}
217
+ `}
218
+ >
219
+ {action.label}
220
+ </button>
221
+ )}
222
+
223
+ {/* Dismiss button */}
224
+ {onDismiss && (
225
+ <button
226
+ onClick={onDismiss}
227
+ className="flex-shrink-0 p-1 rounded-full hover:bg-black/10 transition-colors duration-200"
228
+ aria-label="Dismiss notification"
229
+ >
230
+ <X className="h-4 w-4" />
231
+ </button>
232
+ )}
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ export default NotificationBanner;
@@ -2,8 +2,8 @@
2
2
  export interface ProgressProps{
3
3
  /** Progress value (0-100) */
4
4
  value: number;
5
- /** Progress variant */
6
- variant?: 'linear' | 'circular';
5
+ /** Progress variant ('ring' is alias for 'circular') */
6
+ variant?: 'linear' | 'circular' | 'ring';
7
7
  /** Size variant */
8
8
  size?: 'sm' | 'md' | 'lg';
9
9
  /** Color variant */
@@ -48,8 +48,11 @@ export default function Progress({
48
48
  error: 'bg-error-100',
49
49
  };
50
50
 
51
+ // Normalize 'ring' to 'circular'
52
+ const normalizedVariant = variant === 'ring' ? 'circular' : variant;
53
+
51
54
  // Linear progress
52
- if (variant === 'linear') {
55
+ if (normalizedVariant === 'linear') {
53
56
  const heightClasses = {
54
57
  sm: 'h-1',
55
58
  md: 'h-2',