@papernote/ui 1.10.7 → 1.10.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papernote/ui",
3
- "version": "1.10.7",
3
+ "version": "1.10.8",
4
4
  "type": "module",
5
5
  "description": "A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive",
6
6
  "main": "dist/index.js",
@@ -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-3 bg-success-50 border border-success-200 rounded-lg">
249
- <Text size="sm" className="text-success-800">
250
- <strong>Full swipe enabled:</strong> Swipe all the way right to complete, or all the way left to delete.
251
- </Text>
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, 500));
267
- setTodos(todos.filter(t => t.id !== todo.id));
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(todos.filter(t => t.id !== todo.id))
294
+ onClick: () => setTodos(prev => prev.filter(t => t.id !== todo.id))
278
295
  }
279
296
  ]}
280
297
  >
@@ -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
- ? -(containerRef.current?.offsetWidth || 300)
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
- ? (containerRef.current?.offsetWidth || 300)
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
- {/* Full swipe indicator overlay */}
482
- {fullSwipe && fullSwipeProgress > 0.3 && (
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