@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.
- package/dist/components/SwipeableListItem.d.ts +85 -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 +92 -4
- package/dist/index.esm.js +376 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +376 -2
- package/dist/index.js.map +1 -1
- package/dist/styles.css +135 -0
- package/package.json +1 -1
- package/src/components/SwipeableListItem.stories.tsx +514 -0
- package/src/components/SwipeableListItem.tsx +520 -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,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;
|