@papernote/ui 1.10.5 → 1.10.6

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.
@@ -0,0 +1,425 @@
1
+ // SwipeableListItem - Touch gesture-based swipe actions for list items
2
+ // Provides left/right swipe actions with keyboard accessibility
3
+
4
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
5
+ import { Loader2 } from 'lucide-react';
6
+ import type { LucideIcon } from 'lucide-react';
7
+
8
+ /**
9
+ * Action configuration for swipe gestures
10
+ */
11
+ export interface SwipeListAction {
12
+ /** Background color variant or custom Tailwind class */
13
+ color: 'destructive' | 'warning' | 'success' | 'primary' | string;
14
+ /** Lucide icon to display */
15
+ icon: LucideIcon;
16
+ /** Label for accessibility */
17
+ label: string;
18
+ }
19
+
20
+ /**
21
+ * SwipeableListItem component props
22
+ */
23
+ export interface SwipeableListItemProps {
24
+ /** List item content */
25
+ children: React.ReactNode;
26
+ /** Handler called when swiped right past threshold (can be async) */
27
+ onSwipeRight?: () => void | Promise<void>;
28
+ /** Handler called when swiped left past threshold (can be async) */
29
+ onSwipeLeft?: () => void | Promise<void>;
30
+ /** Right swipe action configuration (revealed when swiping right) */
31
+ rightAction?: SwipeListAction;
32
+ /** Left swipe action configuration (revealed when swiping left) */
33
+ leftAction?: SwipeListAction;
34
+ /** Pixels of swipe before action triggers (default: 100) */
35
+ swipeThreshold?: number;
36
+ /** Disable swipe interactions */
37
+ disabled?: boolean;
38
+ /** Additional class name */
39
+ className?: string;
40
+ }
41
+
42
+ // Color classes for action backgrounds
43
+ const getColorClass = (color: SwipeListAction['color']): string => {
44
+ const colorMap: Record<string, string> = {
45
+ destructive: 'bg-error-500',
46
+ warning: 'bg-warning-500',
47
+ success: 'bg-success-500',
48
+ primary: 'bg-accent-500',
49
+ };
50
+ return colorMap[color] || color;
51
+ };
52
+
53
+ /**
54
+ * SwipeableListItem - List item with swipe-to-action functionality
55
+ *
56
+ * Designed for mobile workflows with keyboard accessibility:
57
+ * - Swipe right to approve/confirm
58
+ * - Swipe left to dismiss/delete
59
+ * - Arrow keys for keyboard navigation
60
+ * - Async callback support with loading state
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * <SwipeableListItem
65
+ * onSwipeRight={() => handleApprove()}
66
+ * onSwipeLeft={() => handleDismiss()}
67
+ * rightAction={{
68
+ * icon: Check,
69
+ * color: 'success',
70
+ * label: 'Approve'
71
+ * }}
72
+ * leftAction={{
73
+ * icon: X,
74
+ * color: 'destructive',
75
+ * label: 'Dismiss'
76
+ * }}
77
+ * >
78
+ * <div className="p-4">List item content</div>
79
+ * </SwipeableListItem>
80
+ * ```
81
+ */
82
+ export function SwipeableListItem({
83
+ children,
84
+ onSwipeRight,
85
+ onSwipeLeft,
86
+ rightAction,
87
+ leftAction,
88
+ swipeThreshold = 100,
89
+ disabled = false,
90
+ className = '',
91
+ }: SwipeableListItemProps) {
92
+ const containerRef = useRef<HTMLDivElement>(null);
93
+ const [isDragging, setIsDragging] = useState(false);
94
+ const [offsetX, setOffsetX] = useState(0);
95
+ const [isTriggered, setIsTriggered] = useState<'left' | 'right' | null>(null);
96
+ const [isLoading, setIsLoading] = useState(false);
97
+ const [keyboardDirection, setKeyboardDirection] = useState<'left' | 'right' | null>(null);
98
+
99
+ const startX = useRef(0);
100
+ const startY = useRef(0);
101
+ const isHorizontalSwipe = useRef<boolean | null>(null);
102
+
103
+ // Trigger haptic feedback
104
+ const triggerHaptic = useCallback((style: 'light' | 'medium' | 'heavy' = 'medium') => {
105
+ if ('vibrate' in navigator) {
106
+ const patterns: Record<string, number | number[]> = {
107
+ light: 10,
108
+ medium: 25,
109
+ heavy: [50, 30, 50],
110
+ };
111
+ navigator.vibrate(patterns[style]);
112
+ }
113
+ }, []);
114
+
115
+ // Execute action with async support
116
+ const executeAction = useCallback(async (direction: 'left' | 'right') => {
117
+ const handler = direction === 'right' ? onSwipeRight : onSwipeLeft;
118
+ if (!handler) return;
119
+
120
+ setIsLoading(true);
121
+ triggerHaptic('heavy');
122
+
123
+ // Animate out
124
+ const slideDistance = direction === 'right' ? window.innerWidth : -window.innerWidth;
125
+ setOffsetX(slideDistance);
126
+
127
+ try {
128
+ await handler();
129
+ } finally {
130
+ // Reset state after animation
131
+ setTimeout(() => {
132
+ setOffsetX(0);
133
+ setIsTriggered(null);
134
+ setIsLoading(false);
135
+ setKeyboardDirection(null);
136
+ }, 200);
137
+ }
138
+ }, [onSwipeRight, onSwipeLeft, triggerHaptic]);
139
+
140
+ // Handle drag start
141
+ const handleDragStart = useCallback((clientX: number, clientY: number) => {
142
+ if (disabled || isLoading) return;
143
+
144
+ setIsDragging(true);
145
+ startX.current = clientX;
146
+ startY.current = clientY;
147
+ isHorizontalSwipe.current = null;
148
+ }, [disabled, isLoading]);
149
+
150
+ // Handle drag move
151
+ const handleDragMove = useCallback((clientX: number, clientY: number) => {
152
+ if (!isDragging || disabled || isLoading) return;
153
+
154
+ const deltaX = clientX - startX.current;
155
+ const deltaY = clientY - startY.current;
156
+
157
+ // Determine if this is a horizontal swipe on first significant movement
158
+ if (isHorizontalSwipe.current === null) {
159
+ const absDeltaX = Math.abs(deltaX);
160
+ const absDeltaY = Math.abs(deltaY);
161
+
162
+ if (absDeltaX > 10 || absDeltaY > 10) {
163
+ isHorizontalSwipe.current = absDeltaX > absDeltaY;
164
+ }
165
+ }
166
+
167
+ // Only process horizontal swipes
168
+ if (isHorizontalSwipe.current !== true) return;
169
+
170
+ // Check if we should allow this direction
171
+ const canSwipeRight = onSwipeRight !== undefined && rightAction !== undefined;
172
+ const canSwipeLeft = onSwipeLeft !== undefined && leftAction !== undefined;
173
+
174
+ let newOffset = deltaX;
175
+
176
+ // Limit swipe direction based on available actions
177
+ if (!canSwipeRight && deltaX > 0) newOffset = 0;
178
+ if (!canSwipeLeft && deltaX < 0) newOffset = 0;
179
+
180
+ // Add resistance when exceeding threshold
181
+ const maxSwipe = swipeThreshold * 1.5;
182
+ if (Math.abs(newOffset) > swipeThreshold) {
183
+ const overflow = Math.abs(newOffset) - swipeThreshold;
184
+ const resistance = overflow * 0.3;
185
+ newOffset = newOffset > 0
186
+ ? swipeThreshold + resistance
187
+ : -(swipeThreshold + resistance);
188
+ newOffset = Math.max(-maxSwipe, Math.min(maxSwipe, newOffset));
189
+ }
190
+
191
+ setOffsetX(newOffset);
192
+
193
+ // Check for threshold crossing and trigger haptic
194
+ const newTriggered = Math.abs(newOffset) >= swipeThreshold
195
+ ? (newOffset > 0 ? 'right' : 'left')
196
+ : null;
197
+
198
+ if (newTriggered !== isTriggered) {
199
+ if (newTriggered) {
200
+ triggerHaptic('medium');
201
+ }
202
+ setIsTriggered(newTriggered);
203
+ }
204
+ }, [isDragging, disabled, isLoading, onSwipeRight, onSwipeLeft, rightAction, leftAction, swipeThreshold, isTriggered, triggerHaptic]);
205
+
206
+ // Handle drag end
207
+ const handleDragEnd = useCallback(() => {
208
+ if (!isDragging) return;
209
+
210
+ setIsDragging(false);
211
+
212
+ // Check if action should be triggered
213
+ if (Math.abs(offsetX) >= swipeThreshold) {
214
+ if (offsetX > 0 && onSwipeRight && rightAction) {
215
+ executeAction('right');
216
+ return;
217
+ } else if (offsetX < 0 && onSwipeLeft && leftAction) {
218
+ executeAction('left');
219
+ return;
220
+ }
221
+ }
222
+
223
+ // Snap back
224
+ setOffsetX(0);
225
+ setIsTriggered(null);
226
+ }, [isDragging, offsetX, swipeThreshold, onSwipeRight, onSwipeLeft, rightAction, leftAction, executeAction]);
227
+
228
+ // Touch event handlers
229
+ const handleTouchStart = (e: React.TouchEvent) => {
230
+ handleDragStart(e.touches[0].clientX, e.touches[0].clientY);
231
+ };
232
+
233
+ const handleTouchMove = (e: React.TouchEvent) => {
234
+ handleDragMove(e.touches[0].clientX, e.touches[0].clientY);
235
+
236
+ // Prevent vertical scroll if horizontal swipe
237
+ if (isHorizontalSwipe.current === true) {
238
+ e.preventDefault();
239
+ }
240
+ };
241
+
242
+ const handleTouchEnd = () => {
243
+ handleDragEnd();
244
+ };
245
+
246
+ // Mouse event handlers (for desktop testing)
247
+ const handleMouseDown = (e: React.MouseEvent) => {
248
+ handleDragStart(e.clientX, e.clientY);
249
+ };
250
+
251
+ useEffect(() => {
252
+ if (!isDragging) return;
253
+
254
+ const handleMouseMove = (e: MouseEvent) => {
255
+ handleDragMove(e.clientX, e.clientY);
256
+ };
257
+
258
+ const handleMouseUp = () => {
259
+ handleDragEnd();
260
+ };
261
+
262
+ document.addEventListener('mousemove', handleMouseMove);
263
+ document.addEventListener('mouseup', handleMouseUp);
264
+
265
+ return () => {
266
+ document.removeEventListener('mousemove', handleMouseMove);
267
+ document.removeEventListener('mouseup', handleMouseUp);
268
+ };
269
+ }, [isDragging, handleDragMove, handleDragEnd]);
270
+
271
+ // Keyboard event handlers
272
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
273
+ if (disabled || isLoading) return;
274
+
275
+ const canSwipeRight = onSwipeRight !== undefined && rightAction !== undefined;
276
+ const canSwipeLeft = onSwipeLeft !== undefined && leftAction !== undefined;
277
+
278
+ switch (e.key) {
279
+ case 'ArrowRight':
280
+ if (canSwipeRight) {
281
+ e.preventDefault();
282
+ setKeyboardDirection('right');
283
+ setOffsetX(swipeThreshold);
284
+ setIsTriggered('right');
285
+ triggerHaptic('medium');
286
+ }
287
+ break;
288
+ case 'ArrowLeft':
289
+ if (canSwipeLeft) {
290
+ e.preventDefault();
291
+ setKeyboardDirection('left');
292
+ setOffsetX(-swipeThreshold);
293
+ setIsTriggered('left');
294
+ triggerHaptic('medium');
295
+ }
296
+ break;
297
+ case 'Enter':
298
+ if (keyboardDirection) {
299
+ e.preventDefault();
300
+ executeAction(keyboardDirection);
301
+ }
302
+ break;
303
+ case 'Escape':
304
+ if (keyboardDirection) {
305
+ e.preventDefault();
306
+ setKeyboardDirection(null);
307
+ setOffsetX(0);
308
+ setIsTriggered(null);
309
+ }
310
+ break;
311
+ }
312
+ }, [disabled, isLoading, onSwipeRight, onSwipeLeft, rightAction, leftAction, swipeThreshold, keyboardDirection, executeAction, triggerHaptic]);
313
+
314
+ // Reset keyboard state on blur
315
+ const handleBlur = useCallback(() => {
316
+ if (keyboardDirection) {
317
+ setKeyboardDirection(null);
318
+ setOffsetX(0);
319
+ setIsTriggered(null);
320
+ }
321
+ }, [keyboardDirection]);
322
+
323
+ // Calculate action opacity based on swipe distance
324
+ const rightActionOpacity = offsetX > 0 ? Math.min(1, offsetX / swipeThreshold) : 0;
325
+ const leftActionOpacity = offsetX < 0 ? Math.min(1, Math.abs(offsetX) / swipeThreshold) : 0;
326
+
327
+ // Build aria-label
328
+ const ariaLabel = [
329
+ 'Swipeable list item.',
330
+ rightAction && onSwipeRight ? `Swipe right or press Arrow Right to ${rightAction.label}.` : '',
331
+ leftAction && onSwipeLeft ? `Swipe left or press Arrow Left to ${leftAction.label}.` : '',
332
+ keyboardDirection ? `Press Enter to confirm or Escape to cancel.` : '',
333
+ ].filter(Boolean).join(' ');
334
+
335
+ return (
336
+ <div
337
+ ref={containerRef}
338
+ className={`relative overflow-hidden ${className}`}
339
+ >
340
+ {/* Right action background (revealed when swiping right) */}
341
+ {rightAction && onSwipeRight && (
342
+ <div
343
+ className={`
344
+ absolute inset-y-0 left-0 flex items-center justify-start pl-6
345
+ ${getColorClass(rightAction.color)}
346
+ transition-opacity duration-100
347
+ `}
348
+ style={{
349
+ opacity: rightActionOpacity,
350
+ width: Math.abs(offsetX) + 20,
351
+ }}
352
+ aria-hidden="true"
353
+ >
354
+ <div
355
+ className={`
356
+ text-white transform transition-transform duration-200
357
+ ${isTriggered === 'right' ? 'scale-125' : 'scale-100'}
358
+ `}
359
+ >
360
+ {isLoading && isTriggered === 'right' ? (
361
+ <Loader2 className="h-6 w-6 animate-spin" />
362
+ ) : (
363
+ <rightAction.icon className="h-6 w-6" />
364
+ )}
365
+ </div>
366
+ </div>
367
+ )}
368
+
369
+ {/* Left action background (revealed when swiping left) */}
370
+ {leftAction && onSwipeLeft && (
371
+ <div
372
+ className={`
373
+ absolute inset-y-0 right-0 flex items-center justify-end pr-6
374
+ ${getColorClass(leftAction.color)}
375
+ transition-opacity duration-100
376
+ `}
377
+ style={{
378
+ opacity: leftActionOpacity,
379
+ width: Math.abs(offsetX) + 20,
380
+ }}
381
+ aria-hidden="true"
382
+ >
383
+ <div
384
+ className={`
385
+ text-white transform transition-transform duration-200
386
+ ${isTriggered === 'left' ? 'scale-125' : 'scale-100'}
387
+ `}
388
+ >
389
+ {isLoading && isTriggered === 'left' ? (
390
+ <Loader2 className="h-6 w-6 animate-spin" />
391
+ ) : (
392
+ <leftAction.icon className="h-6 w-6" />
393
+ )}
394
+ </div>
395
+ </div>
396
+ )}
397
+
398
+ {/* List item content */}
399
+ <div
400
+ className={`
401
+ relative bg-white
402
+ ${isDragging ? '' : 'transition-transform duration-200 ease-out'}
403
+ ${disabled ? 'opacity-50 pointer-events-none' : ''}
404
+ ${keyboardDirection ? 'ring-2 ring-accent-500 ring-inset' : ''}
405
+ `}
406
+ style={{
407
+ transform: `translateX(${offsetX}px)`,
408
+ }}
409
+ onTouchStart={handleTouchStart}
410
+ onTouchMove={handleTouchMove}
411
+ onTouchEnd={handleTouchEnd}
412
+ onMouseDown={handleMouseDown}
413
+ onKeyDown={handleKeyDown}
414
+ onBlur={handleBlur}
415
+ role="button"
416
+ aria-label={ariaLabel}
417
+ tabIndex={disabled ? -1 : 0}
418
+ >
419
+ {children}
420
+ </div>
421
+ </div>
422
+ );
423
+ }
424
+
425
+ export default SwipeableListItem;