@papernote/ui 1.10.5 → 1.10.7

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,520 @@
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
+ * Single action configuration for swipe gestures
10
+ */
11
+ export interface SwipeListAction {
12
+ /** Unique identifier for the action */
13
+ id: string;
14
+ /** Background color variant or custom Tailwind class */
15
+ color: 'destructive' | 'warning' | 'success' | 'primary' | 'neutral' | string;
16
+ /** Lucide icon to display */
17
+ icon: LucideIcon;
18
+ /** Label shown below icon */
19
+ label: string;
20
+ /** Click handler (can be async) */
21
+ onClick: () => void | Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * SwipeableListItem component props
26
+ */
27
+ export interface SwipeableListItemProps {
28
+ /** List item content */
29
+ children: React.ReactNode;
30
+ /** Actions shown when swiping left (appear on right side) */
31
+ leftActions?: SwipeListAction[];
32
+ /** Actions shown when swiping right (appear on left side) */
33
+ rightActions?: SwipeListAction[];
34
+ /** Width per action button in pixels (default: 72) */
35
+ actionWidth?: number;
36
+ /** Enable full swipe to trigger first action (default: false) */
37
+ fullSwipe?: boolean;
38
+ /** Full swipe threshold as percentage of container width (default: 0.5) */
39
+ fullSwipeThreshold?: number;
40
+ /** Disable swipe interactions */
41
+ disabled?: boolean;
42
+ /** Additional class name */
43
+ className?: string;
44
+ /** Callback when swipe state changes */
45
+ onSwipeChange?: (direction: 'left' | 'right' | null) => void;
46
+ }
47
+
48
+ // Color classes for action backgrounds
49
+ const getColorClasses = (color: SwipeListAction['color']): { bg: string; hover: string } => {
50
+ const colorMap: Record<string, { bg: string; hover: string }> = {
51
+ destructive: { bg: 'bg-gradient-to-r from-error-500 to-error-600', hover: 'hover:from-error-600 hover:to-error-700' },
52
+ warning: { bg: 'bg-gradient-to-r from-warning-500 to-warning-600', hover: 'hover:from-warning-600 hover:to-warning-700' },
53
+ success: { bg: 'bg-gradient-to-r from-success-500 to-success-600', hover: 'hover:from-success-600 hover:to-success-700' },
54
+ primary: { bg: 'bg-gradient-to-r from-accent-500 to-accent-600', hover: 'hover:from-accent-600 hover:to-accent-700' },
55
+ neutral: { bg: 'bg-gradient-to-r from-paper-400 to-paper-500', hover: 'hover:from-paper-500 hover:to-paper-600' },
56
+ };
57
+ return colorMap[color] || { bg: color, hover: '' };
58
+ };
59
+
60
+ /**
61
+ * SwipeableListItem - List item with swipe-to-reveal action buttons
62
+ *
63
+ * Features:
64
+ * - Multiple actions per side (like email apps)
65
+ * - Full swipe to trigger primary action
66
+ * - Keyboard accessibility (Arrow keys + Tab + Enter)
67
+ * - Async callback support with loading state
68
+ * - Haptic feedback on mobile
69
+ * - Smooth animations and visual polish
70
+ *
71
+ * @example Single action per side
72
+ * ```tsx
73
+ * <SwipeableListItem
74
+ * rightActions={[
75
+ * { id: 'approve', icon: Check, color: 'success', label: 'Approve', onClick: handleApprove }
76
+ * ]}
77
+ * leftActions={[
78
+ * { id: 'delete', icon: Trash, color: 'destructive', label: 'Delete', onClick: handleDelete }
79
+ * ]}
80
+ * >
81
+ * <div className="p-4">List item content</div>
82
+ * </SwipeableListItem>
83
+ * ```
84
+ *
85
+ * @example Multiple actions (email-style)
86
+ * ```tsx
87
+ * <SwipeableListItem
88
+ * leftActions={[
89
+ * { id: 'delete', icon: Trash, color: 'destructive', label: 'Delete', onClick: handleDelete },
90
+ * { id: 'archive', icon: Archive, color: 'warning', label: 'Archive', onClick: handleArchive },
91
+ * ]}
92
+ * rightActions={[
93
+ * { id: 'read', icon: Mail, color: 'primary', label: 'Read', onClick: handleRead },
94
+ * { id: 'star', icon: Star, color: 'warning', label: 'Star', onClick: handleStar },
95
+ * ]}
96
+ * fullSwipe
97
+ * >
98
+ * <EmailListItem />
99
+ * </SwipeableListItem>
100
+ * ```
101
+ */
102
+ export function SwipeableListItem({
103
+ children,
104
+ leftActions = [],
105
+ rightActions = [],
106
+ actionWidth = 72,
107
+ fullSwipe = false,
108
+ fullSwipeThreshold = 0.5,
109
+ disabled = false,
110
+ className = '',
111
+ onSwipeChange,
112
+ }: SwipeableListItemProps) {
113
+ const containerRef = useRef<HTMLDivElement>(null);
114
+ const [isDragging, setIsDragging] = useState(false);
115
+ const [offsetX, setOffsetX] = useState(0);
116
+ const [activeDirection, setActiveDirection] = useState<'left' | 'right' | null>(null);
117
+ const [loadingActionId, setLoadingActionId] = useState<string | null>(null);
118
+ const [focusedActionIndex, setFocusedActionIndex] = useState<number>(-1);
119
+
120
+ const startX = useRef(0);
121
+ const startY = useRef(0);
122
+ const startTime = useRef(0);
123
+ const isHorizontalSwipe = useRef<boolean | null>(null);
124
+
125
+ // Calculate total widths
126
+ const leftActionsWidth = leftActions.length * actionWidth;
127
+ const rightActionsWidth = rightActions.length * actionWidth;
128
+
129
+ // Trigger haptic feedback
130
+ const triggerHaptic = useCallback((style: 'light' | 'medium' | 'heavy' = 'medium') => {
131
+ if ('vibrate' in navigator) {
132
+ const patterns: Record<string, number | number[]> = {
133
+ light: 10,
134
+ medium: 25,
135
+ heavy: [50, 30, 50],
136
+ };
137
+ navigator.vibrate(patterns[style]);
138
+ }
139
+ }, []);
140
+
141
+ // Reset position
142
+ const resetPosition = useCallback(() => {
143
+ setOffsetX(0);
144
+ setActiveDirection(null);
145
+ setFocusedActionIndex(-1);
146
+ onSwipeChange?.(null);
147
+ }, [onSwipeChange]);
148
+
149
+ // Execute action with async support
150
+ const executeAction = useCallback(async (action: SwipeListAction) => {
151
+ setLoadingActionId(action.id);
152
+ triggerHaptic('heavy');
153
+
154
+ try {
155
+ await action.onClick();
156
+ } finally {
157
+ setLoadingActionId(null);
158
+ resetPosition();
159
+ }
160
+ }, [triggerHaptic, resetPosition]);
161
+
162
+ // Handle drag start
163
+ const handleDragStart = useCallback((clientX: number, clientY: number) => {
164
+ if (disabled || loadingActionId) return;
165
+
166
+ setIsDragging(true);
167
+ startX.current = clientX;
168
+ startY.current = clientY;
169
+ startTime.current = Date.now();
170
+ isHorizontalSwipe.current = null;
171
+ }, [disabled, loadingActionId]);
172
+
173
+ // Handle drag move
174
+ const handleDragMove = useCallback((clientX: number, clientY: number) => {
175
+ if (!isDragging || disabled || loadingActionId) return;
176
+
177
+ const deltaX = clientX - startX.current;
178
+ const deltaY = clientY - startY.current;
179
+
180
+ // Determine if this is a horizontal swipe on first significant movement
181
+ if (isHorizontalSwipe.current === null) {
182
+ const absDeltaX = Math.abs(deltaX);
183
+ const absDeltaY = Math.abs(deltaY);
184
+
185
+ if (absDeltaX > 10 || absDeltaY > 10) {
186
+ isHorizontalSwipe.current = absDeltaX > absDeltaY;
187
+ }
188
+ }
189
+
190
+ // Only process horizontal swipes
191
+ if (isHorizontalSwipe.current !== true) return;
192
+
193
+ let newOffset = deltaX;
194
+
195
+ // Swiping left (reveals left actions on right side)
196
+ if (deltaX < 0) {
197
+ if (leftActions.length === 0) {
198
+ newOffset = deltaX * 0.2; // Heavy resistance if no actions
199
+ } else {
200
+ const maxSwipe = fullSwipe
201
+ ? -(containerRef.current?.offsetWidth || 300)
202
+ : -leftActionsWidth;
203
+ newOffset = Math.max(maxSwipe, deltaX);
204
+
205
+ // Apply resistance past the action buttons
206
+ if (newOffset < -leftActionsWidth && !fullSwipe) {
207
+ const overSwipe = newOffset + leftActionsWidth;
208
+ newOffset = -leftActionsWidth + overSwipe * 0.3;
209
+ }
210
+ }
211
+ if (activeDirection !== 'left') {
212
+ setActiveDirection('left');
213
+ onSwipeChange?.('left');
214
+ }
215
+ }
216
+ // Swiping right (reveals right actions on left side)
217
+ else if (deltaX > 0) {
218
+ if (rightActions.length === 0) {
219
+ newOffset = deltaX * 0.2; // Heavy resistance if no actions
220
+ } else {
221
+ const maxSwipe = fullSwipe
222
+ ? (containerRef.current?.offsetWidth || 300)
223
+ : rightActionsWidth;
224
+ newOffset = Math.min(maxSwipe, deltaX);
225
+
226
+ // Apply resistance past the action buttons
227
+ if (newOffset > rightActionsWidth && !fullSwipe) {
228
+ const overSwipe = newOffset - rightActionsWidth;
229
+ newOffset = rightActionsWidth + overSwipe * 0.3;
230
+ }
231
+ }
232
+ if (activeDirection !== 'right') {
233
+ setActiveDirection('right');
234
+ onSwipeChange?.('right');
235
+ }
236
+ }
237
+
238
+ setOffsetX(newOffset);
239
+ }, [isDragging, disabled, loadingActionId, leftActions.length, rightActions.length, leftActionsWidth, rightActionsWidth, fullSwipe, activeDirection, onSwipeChange]);
240
+
241
+ // Handle drag end
242
+ const handleDragEnd = useCallback(() => {
243
+ if (!isDragging) return;
244
+
245
+ setIsDragging(false);
246
+
247
+ const velocity = Math.abs(offsetX) / (Date.now() - startTime.current);
248
+ const containerWidth = containerRef.current?.offsetWidth || 300;
249
+
250
+ // Check for full swipe trigger
251
+ if (fullSwipe) {
252
+ const swipePercentage = Math.abs(offsetX) / containerWidth;
253
+
254
+ if (swipePercentage >= fullSwipeThreshold || velocity > 0.5) {
255
+ if (offsetX < 0 && leftActions.length > 0) {
256
+ executeAction(leftActions[0]);
257
+ return;
258
+ } else if (offsetX > 0 && rightActions.length > 0) {
259
+ executeAction(rightActions[0]);
260
+ return;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Snap to open or closed position
266
+ const threshold = actionWidth * 0.5;
267
+ if (Math.abs(offsetX) >= threshold || velocity > 0.3) {
268
+ // Snap open
269
+ if (offsetX < 0 && leftActions.length > 0) {
270
+ setOffsetX(-leftActionsWidth);
271
+ setActiveDirection('left');
272
+ onSwipeChange?.('left');
273
+ } else if (offsetX > 0 && rightActions.length > 0) {
274
+ setOffsetX(rightActionsWidth);
275
+ setActiveDirection('right');
276
+ onSwipeChange?.('right');
277
+ } else {
278
+ resetPosition();
279
+ }
280
+ } else {
281
+ resetPosition();
282
+ }
283
+ }, [isDragging, offsetX, fullSwipe, fullSwipeThreshold, leftActions, rightActions, leftActionsWidth, rightActionsWidth, actionWidth, executeAction, resetPosition, onSwipeChange]);
284
+
285
+ // Touch event handlers
286
+ const handleTouchStart = (e: React.TouchEvent) => {
287
+ handleDragStart(e.touches[0].clientX, e.touches[0].clientY);
288
+ };
289
+
290
+ const handleTouchMove = (e: React.TouchEvent) => {
291
+ handleDragMove(e.touches[0].clientX, e.touches[0].clientY);
292
+ if (isHorizontalSwipe.current === true) {
293
+ e.preventDefault();
294
+ }
295
+ };
296
+
297
+ const handleTouchEnd = () => {
298
+ handleDragEnd();
299
+ };
300
+
301
+ // Mouse event handlers
302
+ const handleMouseDown = (e: React.MouseEvent) => {
303
+ handleDragStart(e.clientX, e.clientY);
304
+ };
305
+
306
+ useEffect(() => {
307
+ if (!isDragging) return;
308
+
309
+ const handleMouseMove = (e: MouseEvent) => {
310
+ handleDragMove(e.clientX, e.clientY);
311
+ };
312
+
313
+ const handleMouseUp = () => {
314
+ handleDragEnd();
315
+ };
316
+
317
+ document.addEventListener('mousemove', handleMouseMove);
318
+ document.addEventListener('mouseup', handleMouseUp);
319
+
320
+ return () => {
321
+ document.removeEventListener('mousemove', handleMouseMove);
322
+ document.removeEventListener('mouseup', handleMouseUp);
323
+ };
324
+ }, [isDragging, handleDragMove, handleDragEnd]);
325
+
326
+ // Close on outside click
327
+ useEffect(() => {
328
+ if (activeDirection === null) return;
329
+
330
+ const handleClickOutside = (e: MouseEvent) => {
331
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
332
+ resetPosition();
333
+ }
334
+ };
335
+
336
+ document.addEventListener('mousedown', handleClickOutside);
337
+ return () => document.removeEventListener('mousedown', handleClickOutside);
338
+ }, [activeDirection, resetPosition]);
339
+
340
+ // Keyboard navigation
341
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
342
+ if (disabled || loadingActionId) return;
343
+
344
+ const currentActions = activeDirection === 'left' ? leftActions :
345
+ activeDirection === 'right' ? rightActions : [];
346
+
347
+ switch (e.key) {
348
+ case 'ArrowRight':
349
+ e.preventDefault();
350
+ if (activeDirection === null && rightActions.length > 0) {
351
+ setOffsetX(rightActionsWidth);
352
+ setActiveDirection('right');
353
+ setFocusedActionIndex(0);
354
+ onSwipeChange?.('right');
355
+ triggerHaptic('light');
356
+ } else if (activeDirection === 'right' && focusedActionIndex < rightActions.length - 1) {
357
+ setFocusedActionIndex(prev => prev + 1);
358
+ } else if (activeDirection === 'left') {
359
+ resetPosition();
360
+ }
361
+ break;
362
+ case 'ArrowLeft':
363
+ e.preventDefault();
364
+ if (activeDirection === null && leftActions.length > 0) {
365
+ setOffsetX(-leftActionsWidth);
366
+ setActiveDirection('left');
367
+ setFocusedActionIndex(0);
368
+ onSwipeChange?.('left');
369
+ triggerHaptic('light');
370
+ } else if (activeDirection === 'left' && focusedActionIndex < leftActions.length - 1) {
371
+ setFocusedActionIndex(prev => prev + 1);
372
+ } else if (activeDirection === 'right') {
373
+ resetPosition();
374
+ }
375
+ break;
376
+ case 'Tab':
377
+ if (activeDirection !== null && currentActions.length > 0) {
378
+ e.preventDefault();
379
+ if (e.shiftKey) {
380
+ setFocusedActionIndex(prev => prev <= 0 ? currentActions.length - 1 : prev - 1);
381
+ } else {
382
+ setFocusedActionIndex(prev => prev >= currentActions.length - 1 ? 0 : prev + 1);
383
+ }
384
+ }
385
+ break;
386
+ case 'Enter':
387
+ case ' ':
388
+ if (activeDirection !== null && focusedActionIndex >= 0 && focusedActionIndex < currentActions.length) {
389
+ e.preventDefault();
390
+ executeAction(currentActions[focusedActionIndex]);
391
+ }
392
+ break;
393
+ case 'Escape':
394
+ if (activeDirection !== null) {
395
+ e.preventDefault();
396
+ resetPosition();
397
+ }
398
+ break;
399
+ }
400
+ }, [disabled, loadingActionId, activeDirection, leftActions, rightActions, leftActionsWidth, rightActionsWidth, focusedActionIndex, executeAction, resetPosition, onSwipeChange, triggerHaptic]);
401
+
402
+ // Render action button
403
+ const renderActionButton = (action: SwipeListAction, index: number, side: 'left' | 'right') => {
404
+ const { bg, hover } = getColorClasses(action.color);
405
+ const isLoading = loadingActionId === action.id;
406
+ const isFocused = activeDirection === side && focusedActionIndex === index;
407
+ const IconComponent = action.icon;
408
+
409
+ return (
410
+ <button
411
+ key={action.id}
412
+ onClick={(e) => {
413
+ e.stopPropagation();
414
+ executeAction(action);
415
+ }}
416
+ disabled={!!loadingActionId}
417
+ className={`
418
+ flex flex-col items-center justify-center gap-1
419
+ h-full text-white
420
+ ${bg} ${hover}
421
+ transition-all duration-150 ease-out
422
+ focus:outline-none
423
+ ${isFocused ? 'ring-2 ring-white ring-inset scale-105' : ''}
424
+ ${isLoading ? 'opacity-75' : 'active:scale-95'}
425
+ disabled:cursor-not-allowed
426
+ `}
427
+ style={{ width: actionWidth }}
428
+ aria-label={action.label}
429
+ >
430
+ <div className={`transition-transform duration-200 ${isFocused ? 'scale-110' : ''}`}>
431
+ {isLoading ? (
432
+ <Loader2 className="h-5 w-5 animate-spin" />
433
+ ) : (
434
+ <IconComponent className="h-5 w-5" />
435
+ )}
436
+ </div>
437
+ <span className="text-[10px] font-medium uppercase tracking-wide opacity-90">
438
+ {action.label}
439
+ </span>
440
+ </button>
441
+ );
442
+ };
443
+
444
+ // Build aria-label
445
+ const ariaLabel = [
446
+ 'Swipeable list item.',
447
+ rightActions.length > 0 ? `Swipe right for ${rightActions.map(a => a.label).join(', ')}.` : '',
448
+ leftActions.length > 0 ? `Swipe left for ${leftActions.map(a => a.label).join(', ')}.` : '',
449
+ ].filter(Boolean).join(' ');
450
+
451
+ // Calculate visual progress for full swipe indicator
452
+ const fullSwipeProgress = fullSwipe
453
+ ? Math.min(1, Math.abs(offsetX) / ((containerRef.current?.offsetWidth || 300) * fullSwipeThreshold))
454
+ : 0;
455
+
456
+ return (
457
+ <div
458
+ ref={containerRef}
459
+ className={`relative overflow-hidden ${className}`}
460
+ >
461
+ {/* Right actions (revealed when swiping right) */}
462
+ {rightActions.length > 0 && (
463
+ <div
464
+ className="absolute left-0 top-0 bottom-0 flex shadow-inner"
465
+ style={{ width: rightActionsWidth }}
466
+ >
467
+ {rightActions.map((action, index) => renderActionButton(action, index, 'right'))}
468
+ </div>
469
+ )}
470
+
471
+ {/* Left actions (revealed when swiping left) */}
472
+ {leftActions.length > 0 && (
473
+ <div
474
+ className="absolute right-0 top-0 bottom-0 flex shadow-inner"
475
+ style={{ width: leftActionsWidth }}
476
+ >
477
+ {leftActions.map((action, index) => renderActionButton(action, index, 'left'))}
478
+ </div>
479
+ )}
480
+
481
+ {/* Full swipe indicator overlay */}
482
+ {fullSwipe && fullSwipeProgress > 0.3 && (
483
+ <div
484
+ className={`
485
+ absolute inset-0 pointer-events-none
486
+ ${offsetX > 0 ? 'bg-gradient-to-r from-success-500/20 to-transparent' : 'bg-gradient-to-l from-error-500/20 to-transparent'}
487
+ `}
488
+ style={{ opacity: fullSwipeProgress }}
489
+ />
490
+ )}
491
+
492
+ {/* Main content */}
493
+ <div
494
+ className={`
495
+ relative bg-white
496
+ ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}
497
+ ${isDragging ? '' : 'transition-transform duration-200 ease-out'}
498
+ ${disabled ? 'opacity-50 pointer-events-none' : ''}
499
+ ${isDragging ? 'shadow-lg' : activeDirection ? 'shadow-md' : ''}
500
+ `}
501
+ style={{
502
+ transform: `translateX(${offsetX}px)`,
503
+ touchAction: 'pan-y',
504
+ }}
505
+ onTouchStart={handleTouchStart}
506
+ onTouchMove={handleTouchMove}
507
+ onTouchEnd={handleTouchEnd}
508
+ onMouseDown={handleMouseDown}
509
+ onKeyDown={handleKeyDown}
510
+ role="button"
511
+ aria-label={ariaLabel}
512
+ tabIndex={disabled ? -1 : 0}
513
+ >
514
+ {children}
515
+ </div>
516
+ </div>
517
+ );
518
+ }
519
+
520
+ export default SwipeableListItem;