@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.
- package/dist/components/SwipeableListItem.d.ts +66 -0
- package/dist/components/SwipeableListItem.d.ts.map +1 -0
- package/dist/components/Toast.d.ts +7 -1
- package/dist/components/Toast.d.ts.map +1 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +73 -4
- package/dist/index.esm.js +292 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +292 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/SwipeableListItem.stories.tsx +442 -0
- package/src/components/SwipeableListItem.tsx +425 -0
- package/src/components/Toast.stories.tsx +449 -0
- package/src/components/Toast.tsx +23 -1
- package/src/components/index.ts +4 -1
|
@@ -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;
|