@papernote/ui 1.10.7 → 1.10.9
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 +1 -1
- package/dist/components/SwipeableListItem.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +56 -9
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +56 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/SwipeableListItem.stories.tsx +25 -8
- package/src/components/SwipeableListItem.tsx +86 -12
package/package.json
CHANGED
|
@@ -233,7 +233,7 @@ export const MultipleActions: Story = {
|
|
|
233
233
|
};
|
|
234
234
|
|
|
235
235
|
/**
|
|
236
|
-
* Full swipe to trigger action
|
|
236
|
+
* Full swipe to trigger action - swipe past threshold and release
|
|
237
237
|
*/
|
|
238
238
|
export const FullSwipe: Story = {
|
|
239
239
|
render: () => {
|
|
@@ -241,14 +241,31 @@ export const FullSwipe: Story = {
|
|
|
241
241
|
{ id: 1, text: 'Review pull request', done: false },
|
|
242
242
|
{ id: 2, text: 'Update documentation', done: false },
|
|
243
243
|
{ id: 3, text: 'Fix CI pipeline', done: false },
|
|
244
|
+
{ id: 4, text: 'Write unit tests', done: false },
|
|
244
245
|
]);
|
|
245
246
|
|
|
246
247
|
return (
|
|
247
248
|
<Stack gap="md">
|
|
248
|
-
<div className="p-
|
|
249
|
-
<
|
|
250
|
-
<
|
|
251
|
-
|
|
249
|
+
<div className="p-4 bg-accent-50 border border-accent-200 rounded-lg">
|
|
250
|
+
<Stack gap="sm">
|
|
251
|
+
<Text size="sm" weight="semibold" className="text-accent-800">
|
|
252
|
+
Full Swipe Mode
|
|
253
|
+
</Text>
|
|
254
|
+
<Text size="sm" className="text-accent-700">
|
|
255
|
+
Swipe past the threshold (40%) and the action will trigger automatically when you release.
|
|
256
|
+
Watch for the "Release to..." indicator and haptic feedback.
|
|
257
|
+
</Text>
|
|
258
|
+
<Stack direction="horizontal" gap="md" className="mt-2">
|
|
259
|
+
<div className="flex items-center gap-2">
|
|
260
|
+
<div className="w-3 h-3 rounded-full bg-success-500" />
|
|
261
|
+
<Text size="xs" className="text-ink-600">Swipe right → Done</Text>
|
|
262
|
+
</div>
|
|
263
|
+
<div className="flex items-center gap-2">
|
|
264
|
+
<div className="w-3 h-3 rounded-full bg-error-500" />
|
|
265
|
+
<Text size="xs" className="text-ink-600">Swipe left → Delete</Text>
|
|
266
|
+
</div>
|
|
267
|
+
</Stack>
|
|
268
|
+
</Stack>
|
|
252
269
|
</div>
|
|
253
270
|
<Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden">
|
|
254
271
|
{todos.map((todo) => (
|
|
@@ -263,8 +280,8 @@ export const FullSwipe: Story = {
|
|
|
263
280
|
color: 'success',
|
|
264
281
|
label: 'Done',
|
|
265
282
|
onClick: async () => {
|
|
266
|
-
await new Promise(r => setTimeout(r,
|
|
267
|
-
setTodos(
|
|
283
|
+
await new Promise(r => setTimeout(r, 300));
|
|
284
|
+
setTodos(prev => prev.filter(t => t.id !== todo.id));
|
|
268
285
|
}
|
|
269
286
|
}
|
|
270
287
|
]}
|
|
@@ -274,7 +291,7 @@ export const FullSwipe: Story = {
|
|
|
274
291
|
icon: Trash,
|
|
275
292
|
color: 'destructive',
|
|
276
293
|
label: 'Delete',
|
|
277
|
-
onClick: () => setTodos(
|
|
294
|
+
onClick: () => setTodos(prev => prev.filter(t => t.id !== todo.id))
|
|
278
295
|
}
|
|
279
296
|
]}
|
|
280
297
|
>
|
|
@@ -33,7 +33,7 @@ export interface SwipeableListItemProps {
|
|
|
33
33
|
rightActions?: SwipeListAction[];
|
|
34
34
|
/** Width per action button in pixels (default: 72) */
|
|
35
35
|
actionWidth?: number;
|
|
36
|
-
/** Enable full swipe to trigger first action (default:
|
|
36
|
+
/** Enable full swipe to trigger first action (default: true) */
|
|
37
37
|
fullSwipe?: boolean;
|
|
38
38
|
/** Full swipe threshold as percentage of container width (default: 0.5) */
|
|
39
39
|
fullSwipeThreshold?: number;
|
|
@@ -104,7 +104,7 @@ export function SwipeableListItem({
|
|
|
104
104
|
leftActions = [],
|
|
105
105
|
rightActions = [],
|
|
106
106
|
actionWidth = 72,
|
|
107
|
-
fullSwipe =
|
|
107
|
+
fullSwipe = true,
|
|
108
108
|
fullSwipeThreshold = 0.5,
|
|
109
109
|
disabled = false,
|
|
110
110
|
className = '',
|
|
@@ -116,11 +116,13 @@ export function SwipeableListItem({
|
|
|
116
116
|
const [activeDirection, setActiveDirection] = useState<'left' | 'right' | null>(null);
|
|
117
117
|
const [loadingActionId, setLoadingActionId] = useState<string | null>(null);
|
|
118
118
|
const [focusedActionIndex, setFocusedActionIndex] = useState<number>(-1);
|
|
119
|
+
const [isCommitted, setIsCommitted] = useState(false); // Tracks if past full-swipe threshold
|
|
119
120
|
|
|
120
121
|
const startX = useRef(0);
|
|
121
122
|
const startY = useRef(0);
|
|
122
123
|
const startTime = useRef(0);
|
|
123
124
|
const isHorizontalSwipe = useRef<boolean | null>(null);
|
|
125
|
+
const wasCommitted = useRef(false); // Track previous committed state for haptic
|
|
124
126
|
|
|
125
127
|
// Calculate total widths
|
|
126
128
|
const leftActionsWidth = leftActions.length * actionWidth;
|
|
@@ -143,6 +145,8 @@ export function SwipeableListItem({
|
|
|
143
145
|
setOffsetX(0);
|
|
144
146
|
setActiveDirection(null);
|
|
145
147
|
setFocusedActionIndex(-1);
|
|
148
|
+
setIsCommitted(false);
|
|
149
|
+
wasCommitted.current = false;
|
|
146
150
|
onSwipeChange?.(null);
|
|
147
151
|
}, [onSwipeChange]);
|
|
148
152
|
|
|
@@ -191,6 +195,7 @@ export function SwipeableListItem({
|
|
|
191
195
|
if (isHorizontalSwipe.current !== true) return;
|
|
192
196
|
|
|
193
197
|
let newOffset = deltaX;
|
|
198
|
+
const containerWidth = containerRef.current?.offsetWidth || 300;
|
|
194
199
|
|
|
195
200
|
// Swiping left (reveals left actions on right side)
|
|
196
201
|
if (deltaX < 0) {
|
|
@@ -198,7 +203,7 @@ export function SwipeableListItem({
|
|
|
198
203
|
newOffset = deltaX * 0.2; // Heavy resistance if no actions
|
|
199
204
|
} else {
|
|
200
205
|
const maxSwipe = fullSwipe
|
|
201
|
-
? -
|
|
206
|
+
? -containerWidth
|
|
202
207
|
: -leftActionsWidth;
|
|
203
208
|
newOffset = Math.max(maxSwipe, deltaX);
|
|
204
209
|
|
|
@@ -219,7 +224,7 @@ export function SwipeableListItem({
|
|
|
219
224
|
newOffset = deltaX * 0.2; // Heavy resistance if no actions
|
|
220
225
|
} else {
|
|
221
226
|
const maxSwipe = fullSwipe
|
|
222
|
-
?
|
|
227
|
+
? containerWidth
|
|
223
228
|
: rightActionsWidth;
|
|
224
229
|
newOffset = Math.min(maxSwipe, deltaX);
|
|
225
230
|
|
|
@@ -235,8 +240,22 @@ export function SwipeableListItem({
|
|
|
235
240
|
}
|
|
236
241
|
}
|
|
237
242
|
|
|
243
|
+
// Check if we've crossed the full-swipe threshold
|
|
244
|
+
if (fullSwipe) {
|
|
245
|
+
const swipePercentage = Math.abs(newOffset) / containerWidth;
|
|
246
|
+
const nowCommitted = swipePercentage >= fullSwipeThreshold;
|
|
247
|
+
|
|
248
|
+
// Haptic feedback when crossing threshold (both directions)
|
|
249
|
+
if (nowCommitted !== wasCommitted.current) {
|
|
250
|
+
triggerHaptic(nowCommitted ? 'heavy' : 'light');
|
|
251
|
+
wasCommitted.current = nowCommitted;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
setIsCommitted(nowCommitted);
|
|
255
|
+
}
|
|
256
|
+
|
|
238
257
|
setOffsetX(newOffset);
|
|
239
|
-
}, [isDragging, disabled, loadingActionId, leftActions.length, rightActions.length, leftActionsWidth, rightActionsWidth, fullSwipe, activeDirection, onSwipeChange]);
|
|
258
|
+
}, [isDragging, disabled, loadingActionId, leftActions.length, rightActions.length, leftActionsWidth, rightActionsWidth, fullSwipe, fullSwipeThreshold, activeDirection, onSwipeChange, triggerHaptic]);
|
|
240
259
|
|
|
241
260
|
// Handle drag end
|
|
242
261
|
const handleDragEnd = useCallback(() => {
|
|
@@ -247,11 +266,21 @@ export function SwipeableListItem({
|
|
|
247
266
|
const velocity = Math.abs(offsetX) / (Date.now() - startTime.current);
|
|
248
267
|
const containerWidth = containerRef.current?.offsetWidth || 300;
|
|
249
268
|
|
|
250
|
-
// Check for full swipe trigger
|
|
251
|
-
if (fullSwipe) {
|
|
269
|
+
// Check for full swipe trigger - use isCommitted state for more reliable detection
|
|
270
|
+
if (fullSwipe && isCommitted) {
|
|
271
|
+
if (offsetX < 0 && leftActions.length > 0) {
|
|
272
|
+
executeAction(leftActions[0]);
|
|
273
|
+
return;
|
|
274
|
+
} else if (offsetX > 0 && rightActions.length > 0) {
|
|
275
|
+
executeAction(rightActions[0]);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Also check velocity-based trigger for quick swipes
|
|
281
|
+
if (fullSwipe && velocity > 0.5) {
|
|
252
282
|
const swipePercentage = Math.abs(offsetX) / containerWidth;
|
|
253
|
-
|
|
254
|
-
if (swipePercentage >= fullSwipeThreshold || velocity > 0.5) {
|
|
283
|
+
if (swipePercentage >= fullSwipeThreshold * 0.5) { // Lower threshold for fast swipes
|
|
255
284
|
if (offsetX < 0 && leftActions.length > 0) {
|
|
256
285
|
executeAction(leftActions[0]);
|
|
257
286
|
return;
|
|
@@ -261,6 +290,10 @@ export function SwipeableListItem({
|
|
|
261
290
|
}
|
|
262
291
|
}
|
|
263
292
|
}
|
|
293
|
+
|
|
294
|
+
// Reset committed state
|
|
295
|
+
setIsCommitted(false);
|
|
296
|
+
wasCommitted.current = false;
|
|
264
297
|
|
|
265
298
|
// Snap to open or closed position
|
|
266
299
|
const threshold = actionWidth * 0.5;
|
|
@@ -280,7 +313,7 @@ export function SwipeableListItem({
|
|
|
280
313
|
} else {
|
|
281
314
|
resetPosition();
|
|
282
315
|
}
|
|
283
|
-
}, [isDragging, offsetX, fullSwipe, fullSwipeThreshold, leftActions, rightActions, leftActionsWidth, rightActionsWidth, actionWidth, executeAction, resetPosition, onSwipeChange]);
|
|
316
|
+
}, [isDragging, offsetX, fullSwipe, fullSwipeThreshold, isCommitted, leftActions, rightActions, leftActionsWidth, rightActionsWidth, actionWidth, executeAction, resetPosition, onSwipeChange]);
|
|
284
317
|
|
|
285
318
|
// Touch event handlers
|
|
286
319
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
@@ -478,8 +511,49 @@ export function SwipeableListItem({
|
|
|
478
511
|
</div>
|
|
479
512
|
)}
|
|
480
513
|
|
|
481
|
-
{/*
|
|
482
|
-
{fullSwipe &&
|
|
514
|
+
{/* Committed full-swipe indicator - shows action that will trigger on release */}
|
|
515
|
+
{fullSwipe && isCommitted && isDragging && (
|
|
516
|
+
<div
|
|
517
|
+
className={`
|
|
518
|
+
absolute inset-0 z-10 flex items-center
|
|
519
|
+
${offsetX > 0 ? 'justify-start pl-6' : 'justify-end pr-6'}
|
|
520
|
+
${offsetX > 0 && rightActions.length > 0
|
|
521
|
+
? getColorClasses(rightActions[0].color).bg
|
|
522
|
+
: offsetX < 0 && leftActions.length > 0
|
|
523
|
+
? getColorClasses(leftActions[0].color).bg
|
|
524
|
+
: ''}
|
|
525
|
+
transition-opacity duration-150
|
|
526
|
+
`}
|
|
527
|
+
>
|
|
528
|
+
{offsetX > 0 && rightActions.length > 0 && (() => {
|
|
529
|
+
const action = rightActions[0];
|
|
530
|
+
const IconComponent = action.icon;
|
|
531
|
+
return (
|
|
532
|
+
<div className="flex items-center gap-3 text-white animate-pulse">
|
|
533
|
+
<IconComponent className="h-8 w-8" />
|
|
534
|
+
<span className="text-lg font-semibold uppercase tracking-wide">
|
|
535
|
+
Release to {action.label}
|
|
536
|
+
</span>
|
|
537
|
+
</div>
|
|
538
|
+
);
|
|
539
|
+
})()}
|
|
540
|
+
{offsetX < 0 && leftActions.length > 0 && (() => {
|
|
541
|
+
const action = leftActions[0];
|
|
542
|
+
const IconComponent = action.icon;
|
|
543
|
+
return (
|
|
544
|
+
<div className="flex items-center gap-3 text-white animate-pulse">
|
|
545
|
+
<span className="text-lg font-semibold uppercase tracking-wide">
|
|
546
|
+
Release to {action.label}
|
|
547
|
+
</span>
|
|
548
|
+
<IconComponent className="h-8 w-8" />
|
|
549
|
+
</div>
|
|
550
|
+
);
|
|
551
|
+
})()}
|
|
552
|
+
</div>
|
|
553
|
+
)}
|
|
554
|
+
|
|
555
|
+
{/* Full swipe progress indicator (before committed) */}
|
|
556
|
+
{fullSwipe && fullSwipeProgress > 0.3 && !isCommitted && (
|
|
483
557
|
<div
|
|
484
558
|
className={`
|
|
485
559
|
absolute inset-0 pointer-events-none
|