@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,347 @@
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { Check, MoreHorizontal } from 'lucide-react';
3
+
4
+ export interface SwipeAction {
5
+ /** Icon to display in action area */
6
+ icon: React.ReactNode;
7
+ /** Background color variant */
8
+ color: 'success' | 'error' | 'warning' | 'neutral' | 'primary';
9
+ /** Label for accessibility */
10
+ label: string;
11
+ }
12
+
13
+ export interface SwipeableCardProps {
14
+ /** Card content */
15
+ children: React.ReactNode;
16
+ /** Handler called when swiped right past threshold */
17
+ onSwipeRight?: () => void;
18
+ /** Handler called when swiped left past threshold */
19
+ onSwipeLeft?: () => void;
20
+ /** Right swipe action configuration */
21
+ rightAction?: SwipeAction;
22
+ /** Left swipe action configuration */
23
+ leftAction?: SwipeAction;
24
+ /** Pixels of swipe before action triggers */
25
+ swipeThreshold?: number;
26
+ /** Enable haptic feedback on mobile (if supported) */
27
+ hapticFeedback?: boolean;
28
+ /** Disable swipe interactions */
29
+ disabled?: boolean;
30
+ /** Callback when swipe starts */
31
+ onSwipeStart?: () => void;
32
+ /** Callback when swipe ends (regardless of trigger) */
33
+ onSwipeEnd?: () => void;
34
+ /** Additional class name */
35
+ className?: string;
36
+ }
37
+
38
+ /**
39
+ * SwipeableCard - Card component with swipe-to-action functionality
40
+ *
41
+ * Designed for mobile approval workflows:
42
+ * - Swipe right to approve/confirm
43
+ * - Swipe left to see options/alternatives
44
+ * - Visual feedback showing action being revealed
45
+ * - Haptic feedback on mobile devices
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * <SwipeableCard
50
+ * onSwipeRight={() => handleApprove()}
51
+ * onSwipeLeft={() => handleShowOptions()}
52
+ * rightAction={{
53
+ * icon: <Check />,
54
+ * color: 'success',
55
+ * label: 'Approve'
56
+ * }}
57
+ * leftAction={{
58
+ * icon: <MoreHorizontal />,
59
+ * color: 'neutral',
60
+ * label: 'Options'
61
+ * }}
62
+ * >
63
+ * <TransactionContent />
64
+ * </SwipeableCard>
65
+ * ```
66
+ */
67
+ export function SwipeableCard({
68
+ children,
69
+ onSwipeRight,
70
+ onSwipeLeft,
71
+ rightAction = {
72
+ icon: <Check className="h-6 w-6" />,
73
+ color: 'success',
74
+ label: 'Approve',
75
+ },
76
+ leftAction = {
77
+ icon: <MoreHorizontal className="h-6 w-6" />,
78
+ color: 'neutral',
79
+ label: 'Options',
80
+ },
81
+ swipeThreshold = 100,
82
+ hapticFeedback = true,
83
+ disabled = false,
84
+ onSwipeStart,
85
+ onSwipeEnd,
86
+ className = '',
87
+ }: SwipeableCardProps) {
88
+ const cardRef = useRef<HTMLDivElement>(null);
89
+ const [isDragging, setIsDragging] = useState(false);
90
+ const [offsetX, setOffsetX] = useState(0);
91
+ const [isTriggered, setIsTriggered] = useState<'left' | 'right' | null>(null);
92
+ const startX = useRef(0);
93
+ const startY = useRef(0);
94
+ const isHorizontalSwipe = useRef<boolean | null>(null);
95
+
96
+ // Color classes for action backgrounds
97
+ const colorClasses: Record<SwipeAction['color'], string> = {
98
+ success: 'bg-success-500',
99
+ error: 'bg-error-500',
100
+ warning: 'bg-warning-500',
101
+ neutral: 'bg-paper-400',
102
+ primary: 'bg-accent-500',
103
+ };
104
+
105
+ // Trigger haptic feedback
106
+ const triggerHaptic = useCallback((style: 'light' | 'medium' | 'heavy' = 'medium') => {
107
+ if (!hapticFeedback) return;
108
+
109
+ // Use Vibration API if available
110
+ if ('vibrate' in navigator) {
111
+ const patterns: Record<string, number | number[]> = {
112
+ light: 10,
113
+ medium: 25,
114
+ heavy: [50, 30, 50],
115
+ };
116
+ navigator.vibrate(patterns[style]);
117
+ }
118
+ }, [hapticFeedback]);
119
+
120
+ // Handle drag start
121
+ const handleDragStart = useCallback((clientX: number, clientY: number) => {
122
+ if (disabled) return;
123
+
124
+ setIsDragging(true);
125
+ startX.current = clientX;
126
+ startY.current = clientY;
127
+ isHorizontalSwipe.current = null;
128
+ onSwipeStart?.();
129
+ }, [disabled, onSwipeStart]);
130
+
131
+ // Handle drag move
132
+ const handleDragMove = useCallback((clientX: number, clientY: number) => {
133
+ if (!isDragging || disabled) return;
134
+
135
+ const deltaX = clientX - startX.current;
136
+ const deltaY = clientY - startY.current;
137
+
138
+ // Determine if this is a horizontal swipe on first significant movement
139
+ if (isHorizontalSwipe.current === null) {
140
+ const absDeltaX = Math.abs(deltaX);
141
+ const absDeltaY = Math.abs(deltaY);
142
+
143
+ if (absDeltaX > 10 || absDeltaY > 10) {
144
+ isHorizontalSwipe.current = absDeltaX > absDeltaY;
145
+ }
146
+ }
147
+
148
+ // Only process horizontal swipes
149
+ if (isHorizontalSwipe.current !== true) return;
150
+
151
+ // Check if we should allow this direction
152
+ const canSwipeRight = onSwipeRight !== undefined;
153
+ const canSwipeLeft = onSwipeLeft !== undefined;
154
+
155
+ let newOffset = deltaX;
156
+
157
+ // Limit swipe direction based on available actions
158
+ if (!canSwipeRight && deltaX > 0) newOffset = 0;
159
+ if (!canSwipeLeft && deltaX < 0) newOffset = 0;
160
+
161
+ // Add resistance when exceeding threshold
162
+ const maxSwipe = swipeThreshold * 1.5;
163
+ if (Math.abs(newOffset) > swipeThreshold) {
164
+ const overflow = Math.abs(newOffset) - swipeThreshold;
165
+ const resistance = overflow * 0.3;
166
+ newOffset = newOffset > 0
167
+ ? swipeThreshold + resistance
168
+ : -(swipeThreshold + resistance);
169
+ newOffset = Math.max(-maxSwipe, Math.min(maxSwipe, newOffset));
170
+ }
171
+
172
+ setOffsetX(newOffset);
173
+
174
+ // Check for threshold crossing and trigger haptic
175
+ const newTriggered = Math.abs(newOffset) >= swipeThreshold
176
+ ? (newOffset > 0 ? 'right' : 'left')
177
+ : null;
178
+
179
+ if (newTriggered !== isTriggered) {
180
+ if (newTriggered) {
181
+ triggerHaptic('medium');
182
+ }
183
+ setIsTriggered(newTriggered);
184
+ }
185
+ }, [isDragging, disabled, onSwipeRight, onSwipeLeft, swipeThreshold, isTriggered, triggerHaptic]);
186
+
187
+ // Handle drag end
188
+ const handleDragEnd = useCallback(() => {
189
+ if (!isDragging) return;
190
+
191
+ setIsDragging(false);
192
+ onSwipeEnd?.();
193
+
194
+ // Check if action should be triggered
195
+ if (Math.abs(offsetX) >= swipeThreshold) {
196
+ if (offsetX > 0 && onSwipeRight) {
197
+ triggerHaptic('heavy');
198
+ // Animate card away then call handler
199
+ setOffsetX(window.innerWidth);
200
+ setTimeout(() => {
201
+ onSwipeRight();
202
+ setOffsetX(0);
203
+ setIsTriggered(null);
204
+ }, 200);
205
+ return;
206
+ } else if (offsetX < 0 && onSwipeLeft) {
207
+ triggerHaptic('heavy');
208
+ setOffsetX(-window.innerWidth);
209
+ setTimeout(() => {
210
+ onSwipeLeft();
211
+ setOffsetX(0);
212
+ setIsTriggered(null);
213
+ }, 200);
214
+ return;
215
+ }
216
+ }
217
+
218
+ // Snap back
219
+ setOffsetX(0);
220
+ setIsTriggered(null);
221
+ }, [isDragging, offsetX, swipeThreshold, onSwipeRight, onSwipeLeft, onSwipeEnd, triggerHaptic]);
222
+
223
+ // Touch event handlers
224
+ const handleTouchStart = (e: React.TouchEvent) => {
225
+ handleDragStart(e.touches[0].clientX, e.touches[0].clientY);
226
+ };
227
+
228
+ const handleTouchMove = (e: React.TouchEvent) => {
229
+ handleDragMove(e.touches[0].clientX, e.touches[0].clientY);
230
+
231
+ // Prevent vertical scroll if horizontal swipe
232
+ if (isHorizontalSwipe.current === true) {
233
+ e.preventDefault();
234
+ }
235
+ };
236
+
237
+ const handleTouchEnd = () => {
238
+ handleDragEnd();
239
+ };
240
+
241
+ // Mouse event handlers (for desktop testing)
242
+ const handleMouseDown = (e: React.MouseEvent) => {
243
+ handleDragStart(e.clientX, e.clientY);
244
+ };
245
+
246
+ useEffect(() => {
247
+ if (!isDragging) return;
248
+
249
+ const handleMouseMove = (e: MouseEvent) => {
250
+ handleDragMove(e.clientX, e.clientY);
251
+ };
252
+
253
+ const handleMouseUp = () => {
254
+ handleDragEnd();
255
+ };
256
+
257
+ document.addEventListener('mousemove', handleMouseMove);
258
+ document.addEventListener('mouseup', handleMouseUp);
259
+
260
+ return () => {
261
+ document.removeEventListener('mousemove', handleMouseMove);
262
+ document.removeEventListener('mouseup', handleMouseUp);
263
+ };
264
+ }, [isDragging, handleDragMove, handleDragEnd]);
265
+
266
+ // Calculate action opacity based on swipe distance
267
+ const rightActionOpacity = offsetX > 0 ? Math.min(1, offsetX / swipeThreshold) : 0;
268
+ const leftActionOpacity = offsetX < 0 ? Math.min(1, Math.abs(offsetX) / swipeThreshold) : 0;
269
+
270
+ return (
271
+ <div className={`relative overflow-hidden rounded-lg ${className}`}>
272
+ {/* Right action background (revealed when swiping right) */}
273
+ {onSwipeRight && (
274
+ <div
275
+ className={`
276
+ absolute inset-y-0 left-0 flex items-center justify-start pl-6
277
+ ${colorClasses[rightAction.color]}
278
+ transition-opacity duration-100
279
+ `}
280
+ style={{
281
+ opacity: rightActionOpacity,
282
+ width: Math.abs(offsetX) + 20,
283
+ }}
284
+ aria-hidden="true"
285
+ >
286
+ <div
287
+ className={`
288
+ text-white transform transition-transform duration-200
289
+ ${isTriggered === 'right' ? 'scale-125' : 'scale-100'}
290
+ `}
291
+ >
292
+ {rightAction.icon}
293
+ </div>
294
+ </div>
295
+ )}
296
+
297
+ {/* Left action background (revealed when swiping left) */}
298
+ {onSwipeLeft && (
299
+ <div
300
+ className={`
301
+ absolute inset-y-0 right-0 flex items-center justify-end pr-6
302
+ ${colorClasses[leftAction.color]}
303
+ transition-opacity duration-100
304
+ `}
305
+ style={{
306
+ opacity: leftActionOpacity,
307
+ width: Math.abs(offsetX) + 20,
308
+ }}
309
+ aria-hidden="true"
310
+ >
311
+ <div
312
+ className={`
313
+ text-white transform transition-transform duration-200
314
+ ${isTriggered === 'left' ? 'scale-125' : 'scale-100'}
315
+ `}
316
+ >
317
+ {leftAction.icon}
318
+ </div>
319
+ </div>
320
+ )}
321
+
322
+ {/* Card content */}
323
+ <div
324
+ ref={cardRef}
325
+ className={`
326
+ relative bg-white
327
+ ${isDragging ? '' : 'transition-transform duration-200 ease-out'}
328
+ ${disabled ? 'opacity-50 pointer-events-none' : ''}
329
+ `}
330
+ style={{
331
+ transform: `translateX(${offsetX}px)`,
332
+ }}
333
+ onTouchStart={handleTouchStart}
334
+ onTouchMove={handleTouchMove}
335
+ onTouchEnd={handleTouchEnd}
336
+ onMouseDown={handleMouseDown}
337
+ role="button"
338
+ aria-label={`Swipeable card. ${onSwipeRight ? `Swipe right to ${rightAction.label}.` : ''} ${onSwipeLeft ? `Swipe left to ${leftAction.label}.` : ''}`}
339
+ tabIndex={disabled ? -1 : 0}
340
+ >
341
+ {children}
342
+ </div>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ export default SwipeableCard;
@@ -5,13 +5,21 @@ import React, { forwardRef } from 'react';
5
5
 
6
6
  type TextElement = 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label';
7
7
 
8
+ type TextSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl';
9
+
8
10
  export interface TextProps extends Omit<React.HTMLAttributes<HTMLElement>, 'color'> {
9
11
  /** Text content */
10
12
  children: React.ReactNode;
11
13
  /** HTML element to render */
12
14
  as?: TextElement;
13
- /** Size variant */
14
- size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl';
15
+ /** Size variant (base size) */
16
+ size?: TextSize;
17
+ /** Size on small screens (640px+) - overrides base size */
18
+ smSize?: TextSize;
19
+ /** Size on medium screens (768px+) - overrides smaller breakpoints */
20
+ mdSize?: TextSize;
21
+ /** Size on large screens (1024px+) - overrides smaller breakpoints */
22
+ lgSize?: TextSize;
15
23
  /** Weight variant */
16
24
  weight?: 'normal' | 'medium' | 'semibold' | 'bold';
17
25
  /** Color variant */
@@ -58,6 +66,9 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
58
66
  children,
59
67
  as: Component = 'p',
60
68
  size = 'base',
69
+ smSize,
70
+ mdSize,
71
+ lgSize,
61
72
  weight = 'normal',
62
73
  color = 'primary',
63
74
  align = 'left',
@@ -67,7 +78,7 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
67
78
  className = '',
68
79
  ...htmlProps
69
80
  }, ref) => {
70
- const sizeClasses = {
81
+ const sizeClasses: Record<TextSize, string> = {
71
82
  xs: 'text-xs',
72
83
  sm: 'text-sm',
73
84
  base: 'text-base',
@@ -76,6 +87,34 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
76
87
  '2xl': 'text-2xl',
77
88
  };
78
89
 
90
+ // Responsive size classes
91
+ const smSizeClasses: Record<TextSize, string> = {
92
+ xs: 'sm:text-xs',
93
+ sm: 'sm:text-sm',
94
+ base: 'sm:text-base',
95
+ lg: 'sm:text-lg',
96
+ xl: 'sm:text-xl',
97
+ '2xl': 'sm:text-2xl',
98
+ };
99
+
100
+ const mdSizeClasses: Record<TextSize, string> = {
101
+ xs: 'md:text-xs',
102
+ sm: 'md:text-sm',
103
+ base: 'md:text-base',
104
+ lg: 'md:text-lg',
105
+ xl: 'md:text-xl',
106
+ '2xl': 'md:text-2xl',
107
+ };
108
+
109
+ const lgSizeClasses: Record<TextSize, string> = {
110
+ xs: 'lg:text-xs',
111
+ sm: 'lg:text-sm',
112
+ base: 'lg:text-base',
113
+ lg: 'lg:text-lg',
114
+ xl: 'lg:text-xl',
115
+ '2xl': 'lg:text-2xl',
116
+ };
117
+
79
118
  const weightClasses = {
80
119
  normal: 'font-normal',
81
120
  medium: 'font-medium',
@@ -118,6 +157,9 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
118
157
  // Build class list
119
158
  const classes = [
120
159
  sizeClasses[size],
160
+ smSize ? smSizeClasses[smSize] : '',
161
+ mdSize ? mdSizeClasses[mdSize] : '',
162
+ lgSize ? lgSizeClasses[lgSize] : '',
121
163
  weightClasses[weight],
122
164
  colorClasses[color],
123
165
  alignClasses[align],
@@ -189,8 +189,8 @@ export type { ContextMenuProps } from './ContextMenu';
189
189
  export { default as ErrorBoundary } from './ErrorBoundary';
190
190
  export type { ErrorBoundaryProps } from './ErrorBoundary';
191
191
 
192
- export { default as BottomSheet } from './BottomSheet';
193
- export type { BottomSheetProps } from './BottomSheet';
192
+ export { default as BottomSheet, BottomSheetHeader, BottomSheetContent, BottomSheetActions } from './BottomSheet';
193
+ export type { BottomSheetProps, BottomSheetHeaderProps, BottomSheetContentProps, BottomSheetActionsProps } from './BottomSheet';
194
194
 
195
195
  export { default as Collapsible } from './Collapsible';
196
196
  export type { CollapsibleProps } from './Collapsible';
@@ -200,6 +200,19 @@ export type { ExpandablePanelProps } from './ExpandablePanel';
200
200
 
201
201
  export { Show, Hide } from './ResponsiveUtilities';
202
202
 
203
+ // Mobile Components
204
+ export { default as HorizontalScroll } from './HorizontalScroll';
205
+ export type { HorizontalScrollProps } from './HorizontalScroll';
206
+
207
+ export { default as SwipeableCard } from './SwipeableCard';
208
+ export type { SwipeableCardProps, SwipeAction as SwipeableCardAction } from './SwipeableCard';
209
+
210
+ export { default as NotificationBanner } from './NotificationBanner';
211
+ export type { NotificationBannerProps, NotificationBannerAction } from './NotificationBanner';
212
+
213
+ export { default as CompactStat } from './CompactStat';
214
+ export type { CompactStatProps, CompactStatTrend } from './CompactStat';
215
+
203
216
  // Navigation Components
204
217
  export { default as Breadcrumbs, useBreadcrumbReset } from './Breadcrumbs';
205
218
  export type { BreadcrumbsProps, BreadcrumbItem, BreadcrumbNavigationState } from './Breadcrumbs';
@@ -262,7 +275,7 @@ export type { MobileHeaderProps } from './MobileHeader';
262
275
  export { default as FloatingActionButton, useFABScroll } from './FloatingActionButton';
263
276
  export type { FloatingActionButtonProps, FABAction } from './FloatingActionButton';
264
277
 
265
- export { default as PullToRefresh, usePullToRefresh } from './PullToRefresh';
278
+ export { default as PullToRefresh } from './PullToRefresh';
266
279
  export type { PullToRefreshProps } from './PullToRefresh';
267
280
 
268
281
  // Logo
@@ -586,3 +586,35 @@
586
586
  .animate-slide-in-bottom {
587
587
  animation: slideInBottom 0.3s ease-out;
588
588
  }
589
+
590
+ /* Touch-friendly sizing for coarse pointer devices (touch screens) */
591
+ @media (pointer: coarse) {
592
+ /* Ensure buttons meet 48px minimum touch target on touch devices */
593
+ .btn,
594
+ button.touch-friendly,
595
+ .touch-target {
596
+ min-height: 48px;
597
+ min-width: 48px;
598
+ }
599
+
600
+ /* Larger tap targets for inputs on touch devices */
601
+ .input,
602
+ input[type="text"],
603
+ input[type="email"],
604
+ input[type="password"],
605
+ input[type="number"],
606
+ input[type="tel"],
607
+ input[type="url"],
608
+ input[type="search"],
609
+ select,
610
+ textarea {
611
+ min-height: 48px;
612
+ }
613
+
614
+ /* Larger checkbox/radio hit areas */
615
+ input[type="checkbox"],
616
+ input[type="radio"] {
617
+ min-width: 24px;
618
+ min-height: 24px;
619
+ }
620
+ }