@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
|
@@ -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 (
|
|
55
|
+
if (normalizedVariant === 'linear') {
|
|
53
56
|
const heightClasses = {
|
|
54
57
|
sm: 'h-1',
|
|
55
58
|
md: 'h-2',
|