@papernote/ui 1.7.7 → 1.8.0

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.
Files changed (42) hide show
  1. package/dist/components/Badge.d.ts +3 -1
  2. package/dist/components/Badge.d.ts.map +1 -1
  3. package/dist/components/BottomSheet.d.ts +72 -8
  4. package/dist/components/BottomSheet.d.ts.map +1 -1
  5. package/dist/components/CompactStat.d.ts +52 -0
  6. package/dist/components/CompactStat.d.ts.map +1 -0
  7. package/dist/components/HorizontalScroll.d.ts +43 -0
  8. package/dist/components/HorizontalScroll.d.ts.map +1 -0
  9. package/dist/components/NotificationBanner.d.ts +53 -0
  10. package/dist/components/NotificationBanner.d.ts.map +1 -0
  11. package/dist/components/Progress.d.ts +2 -2
  12. package/dist/components/Progress.d.ts.map +1 -1
  13. package/dist/components/PullToRefresh.d.ts +23 -71
  14. package/dist/components/PullToRefresh.d.ts.map +1 -1
  15. package/dist/components/Stack.d.ts +2 -1
  16. package/dist/components/Stack.d.ts.map +1 -1
  17. package/dist/components/SwipeableCard.d.ts +65 -0
  18. package/dist/components/SwipeableCard.d.ts.map +1 -0
  19. package/dist/components/Text.d.ts +9 -2
  20. package/dist/components/Text.d.ts.map +1 -1
  21. package/dist/components/index.d.ts +11 -3
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/index.d.ts +317 -86
  24. package/dist/index.esm.js +932 -253
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/index.js +937 -252
  27. package/dist/index.js.map +1 -1
  28. package/dist/styles.css +178 -8
  29. package/package.json +1 -1
  30. package/src/components/Badge.tsx +13 -2
  31. package/src/components/BottomSheet.tsx +227 -98
  32. package/src/components/Card.tsx +1 -1
  33. package/src/components/CompactStat.tsx +150 -0
  34. package/src/components/HorizontalScroll.tsx +275 -0
  35. package/src/components/NotificationBanner.tsx +238 -0
  36. package/src/components/Progress.tsx +6 -3
  37. package/src/components/PullToRefresh.tsx +158 -196
  38. package/src/components/Stack.tsx +4 -1
  39. package/src/components/SwipeableCard.tsx +347 -0
  40. package/src/components/Text.tsx +45 -3
  41. package/src/components/index.ts +16 -3
  42. package/src/styles/index.css +32 -0
@@ -1,136 +1,155 @@
1
- import React, { useState, useRef, useCallback, useEffect } from 'react';
2
- import { Loader2, ArrowDown } from 'lucide-react';
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { Loader2, ArrowDown, Check } from 'lucide-react';
3
3
 
4
- /**
5
- * PullToRefresh component props
6
- */
7
4
  export interface PullToRefreshProps {
8
- /** Content to wrap */
5
+ /** Content to wrap with pull-to-refresh functionality */
9
6
  children: React.ReactNode;
10
- /** Async refresh handler - should return a Promise */
7
+ /** Async callback when refresh is triggered - should return when refresh is complete */
11
8
  onRefresh: () => Promise<void>;
9
+ /** Pixels to pull before triggering refresh */
10
+ threshold?: number;
12
11
  /** Disable pull-to-refresh */
13
12
  disabled?: boolean;
14
- /** Pull distance required to trigger refresh (default: 80) */
15
- pullThreshold?: number;
16
- /** Maximum pull distance (default: 120) */
17
- maxPull?: number;
18
- /** Custom loading indicator */
19
- loadingIndicator?: React.ReactNode;
20
- /** Custom pull indicator */
21
- pullIndicator?: React.ReactNode;
22
- /** Additional class names for container */
13
+ /** Custom content shown while pulling */
14
+ pullingContent?: React.ReactNode;
15
+ /** Custom content shown when ready to release */
16
+ releaseContent?: React.ReactNode;
17
+ /** Custom content shown while refreshing */
18
+ refreshingContent?: React.ReactNode;
19
+ /** Custom content shown when refresh completes (briefly) */
20
+ completeContent?: React.ReactNode;
21
+ /** Additional class name for container */
23
22
  className?: string;
24
23
  }
25
24
 
26
- type RefreshState = 'idle' | 'pulling' | 'ready' | 'refreshing';
25
+ type RefreshState = 'idle' | 'pulling' | 'ready' | 'refreshing' | 'complete';
27
26
 
28
27
  /**
29
- * PullToRefresh - Mobile pull-to-refresh gesture handler
30
- *
31
- * Wraps content and provides native-feeling pull-to-refresh functionality.
32
- * Only activates when scrolled to top of content.
33
- *
34
- * @example Basic usage
35
- * ```tsx
36
- * <PullToRefresh onRefresh={async () => {
37
- * await fetchLatestData();
38
- * }}>
39
- * <div className="min-h-screen">
40
- * {content}
41
- * </div>
42
- * </PullToRefresh>
43
- * ```
44
- *
45
- * @example With custom threshold
28
+ * PullToRefresh - Pull-down refresh indicator and handler for mobile lists
29
+ *
30
+ * Wraps content to enable pull-to-refresh behavior on mobile:
31
+ * - Pull down to trigger refresh
32
+ * - Visual feedback showing progress
33
+ * - Custom content for each state
34
+ *
35
+ * @example
46
36
  * ```tsx
47
- * <PullToRefresh
48
- * onRefresh={handleRefresh}
49
- * pullThreshold={100}
50
- * maxPull={150}
51
- * >
52
- * {content}
37
+ * <PullToRefresh onRefresh={async () => { await syncData(); }}>
38
+ * <TransactionList transactions={transactions} />
53
39
  * </PullToRefresh>
54
40
  * ```
55
41
  */
56
- export default function PullToRefresh({
42
+ export function PullToRefresh({
57
43
  children,
58
44
  onRefresh,
45
+ threshold = 80,
59
46
  disabled = false,
60
- pullThreshold = 80,
61
- maxPull = 120,
62
- loadingIndicator,
63
- pullIndicator,
47
+ pullingContent,
48
+ releaseContent,
49
+ refreshingContent,
50
+ completeContent,
64
51
  className = '',
65
52
  }: PullToRefreshProps) {
53
+ const containerRef = useRef<HTMLDivElement>(null);
66
54
  const [state, setState] = useState<RefreshState>('idle');
67
55
  const [pullDistance, setPullDistance] = useState(0);
68
- const containerRef = useRef<HTMLDivElement>(null);
69
56
  const startY = useRef(0);
70
57
  const currentY = useRef(0);
58
+ const isDragging = useRef(false);
71
59
 
72
- // Check if at top of scroll container
60
+ // Check if content is at top (can pull to refresh)
73
61
  const isAtTop = useCallback(() => {
74
62
  const container = containerRef.current;
75
63
  if (!container) return false;
76
- return container.scrollTop <= 0;
64
+
65
+ // Check if the scrollable content is at the top
66
+ const scrollableParent = container.querySelector('[data-ptr-scrollable]') || container;
67
+ return (scrollableParent as HTMLElement).scrollTop <= 0;
77
68
  }, []);
78
69
 
79
- // Handle touch start
70
+ // Handle pull start
80
71
  const handleTouchStart = useCallback((e: TouchEvent) => {
81
- if (disabled || state === 'refreshing' || !isAtTop()) return;
82
-
72
+ if (disabled || state === 'refreshing') return;
73
+ if (!isAtTop()) return;
74
+
75
+ isDragging.current = true;
83
76
  startY.current = e.touches[0].clientY;
84
- currentY.current = startY.current;
77
+ currentY.current = e.touches[0].clientY;
85
78
  }, [disabled, state, isAtTop]);
86
79
 
87
- // Handle touch move
80
+ // Handle pull move
88
81
  const handleTouchMove = useCallback((e: TouchEvent) => {
89
- if (disabled || state === 'refreshing') return;
90
- if (startY.current === 0) return;
82
+ if (!isDragging.current || disabled || state === 'refreshing') return;
91
83
 
92
84
  currentY.current = e.touches[0].clientY;
93
- const diff = currentY.current - startY.current;
85
+ const delta = currentY.current - startY.current;
86
+
87
+ // Only activate pull-to-refresh when pulling down
88
+ if (delta < 0) {
89
+ isDragging.current = false;
90
+ setPullDistance(0);
91
+ setState('idle');
92
+ return;
93
+ }
94
+
95
+ // Check if we're at the top before allowing pull
96
+ if (!isAtTop()) {
97
+ isDragging.current = false;
98
+ return;
99
+ }
94
100
 
95
- // Only allow pulling down when at top
96
- if (diff > 0 && isAtTop()) {
97
- // Apply resistance - pull slows down as distance increases
98
- const resistance = 0.5;
99
- const adjustedPull = Math.min(diff * resistance, maxPull);
100
-
101
- setPullDistance(adjustedPull);
102
- setState(adjustedPull >= pullThreshold ? 'ready' : 'pulling');
101
+ // Apply resistance to pull
102
+ const resistance = 0.5;
103
+ const resistedDelta = delta * resistance;
104
+ const maxPull = threshold * 2;
105
+ const clampedDelta = Math.min(resistedDelta, maxPull);
103
106
 
104
- // Prevent default scroll when pulling
105
- if (adjustedPull > 0) {
106
- e.preventDefault();
107
- }
107
+ setPullDistance(clampedDelta);
108
+
109
+ // Update state based on pull distance
110
+ if (clampedDelta >= threshold) {
111
+ setState('ready');
112
+ } else if (clampedDelta > 0) {
113
+ setState('pulling');
114
+ }
115
+
116
+ // Prevent default scroll when pulling
117
+ if (delta > 0 && isAtTop()) {
118
+ e.preventDefault();
108
119
  }
109
- }, [disabled, state, isAtTop, maxPull, pullThreshold]);
120
+ }, [disabled, state, threshold, isAtTop]);
110
121
 
111
- // Handle touch end
122
+ // Handle pull end
112
123
  const handleTouchEnd = useCallback(async () => {
113
- if (disabled || state === 'refreshing') return;
124
+ if (!isDragging.current) return;
125
+ isDragging.current = false;
114
126
 
115
- if (state === 'ready') {
127
+ if (state === 'ready' && pullDistance >= threshold) {
116
128
  setState('refreshing');
117
- setPullDistance(pullThreshold); // Hold at threshold while refreshing
129
+ setPullDistance(threshold * 0.6); // Settle at a smaller height while refreshing
118
130
 
119
131
  try {
120
132
  await onRefresh();
133
+ setState('complete');
134
+
135
+ // Show complete state briefly
136
+ setTimeout(() => {
137
+ setState('idle');
138
+ setPullDistance(0);
139
+ }, 500);
121
140
  } catch (error) {
122
141
  console.error('Refresh failed:', error);
142
+ setState('idle');
143
+ setPullDistance(0);
123
144
  }
124
-
145
+ } else {
146
+ // Snap back
125
147
  setState('idle');
148
+ setPullDistance(0);
126
149
  }
150
+ }, [state, pullDistance, threshold, onRefresh]);
127
151
 
128
- setPullDistance(0);
129
- startY.current = 0;
130
- currentY.current = 0;
131
- }, [disabled, state, pullThreshold, onRefresh]);
132
-
133
- // Attach touch listeners
152
+ // Attach touch event listeners
134
153
  useEffect(() => {
135
154
  const container = containerRef.current;
136
155
  if (!container) return;
@@ -146,65 +165,84 @@ export default function PullToRefresh({
146
165
  };
147
166
  }, [handleTouchStart, handleTouchMove, handleTouchEnd]);
148
167
 
149
- // Calculate indicator opacity and rotation
150
- const progress = Math.min(pullDistance / pullThreshold, 1);
151
- const rotation = progress * 180;
168
+ // Calculate progress percentage
169
+ const progress = Math.min(1, pullDistance / threshold);
152
170
 
153
- // Default loading indicator
154
- const defaultLoadingIndicator = (
155
- <Loader2 className="h-6 w-6 text-accent-600 animate-spin" />
171
+ // Default content for each state
172
+ const defaultPullingContent = (
173
+ <div className="flex flex-col items-center gap-1">
174
+ <ArrowDown
175
+ className="h-5 w-5 text-ink-400 transition-transform duration-200"
176
+ style={{ transform: `rotate(${progress * 180}deg)` }}
177
+ />
178
+ <span className="text-xs text-ink-500">Pull to refresh</span>
179
+ </div>
156
180
  );
157
181
 
158
- // Default pull indicator
159
- const defaultPullIndicator = (
160
- <div
161
- className={`
162
- transition-transform duration-200
163
- ${state === 'ready' ? 'text-accent-600' : 'text-ink-400'}
164
- `}
165
- style={{ transform: `rotate(${rotation}deg)` }}
166
- >
167
- <ArrowDown className="h-6 w-6" />
182
+ const defaultReleaseContent = (
183
+ <div className="flex flex-col items-center gap-1">
184
+ <ArrowDown
185
+ className="h-5 w-5 text-accent-500 rotate-180"
186
+ />
187
+ <span className="text-xs text-accent-600 font-medium">Release to refresh</span>
188
+ </div>
189
+ );
190
+
191
+ const defaultRefreshingContent = (
192
+ <div className="flex flex-col items-center gap-1">
193
+ <Loader2 className="h-5 w-5 text-accent-500 animate-spin" />
194
+ <span className="text-xs text-ink-500">Refreshing...</span>
195
+ </div>
196
+ );
197
+
198
+ const defaultCompleteContent = (
199
+ <div className="flex flex-col items-center gap-1">
200
+ <Check className="h-5 w-5 text-success-500" />
201
+ <span className="text-xs text-success-600">Done!</span>
168
202
  </div>
169
203
  );
170
204
 
205
+ // Get content based on current state
206
+ const getIndicatorContent = () => {
207
+ switch (state) {
208
+ case 'pulling':
209
+ return pullingContent || defaultPullingContent;
210
+ case 'ready':
211
+ return releaseContent || defaultReleaseContent;
212
+ case 'refreshing':
213
+ return refreshingContent || defaultRefreshingContent;
214
+ case 'complete':
215
+ return completeContent || defaultCompleteContent;
216
+ default:
217
+ return null;
218
+ }
219
+ };
220
+
171
221
  return (
172
- <div
222
+ <div
173
223
  ref={containerRef}
174
- className={`relative overflow-auto ${className}`}
175
- style={{ touchAction: pullDistance > 0 ? 'none' : 'auto' }}
224
+ className={`relative overflow-hidden ${className}`}
176
225
  >
177
226
  {/* Pull indicator */}
178
227
  <div
179
228
  className={`
180
- absolute left-0 right-0 flex items-center justify-center
181
- transition-all duration-200 overflow-hidden
182
- ${state === 'idle' && pullDistance === 0 ? 'opacity-0' : 'opacity-100'}
229
+ absolute top-0 left-0 right-0
230
+ flex items-center justify-center
231
+ bg-paper-50
232
+ transition-all duration-200 ease-out
233
+ ${state === 'idle' ? 'opacity-0' : 'opacity-100'}
183
234
  `}
184
235
  style={{
185
- height: `${pullDistance}px`,
186
- top: 0,
187
- zIndex: 10,
236
+ height: pullDistance,
237
+ transform: state === 'idle' ? 'translateY(-100%)' : 'translateY(0)',
188
238
  }}
189
239
  >
190
- <div
191
- className={`
192
- w-10 h-10 rounded-full bg-white shadow-md
193
- flex items-center justify-center
194
- transition-transform duration-200
195
- ${state === 'refreshing' ? 'scale-100' : progress < 0.3 ? 'scale-75' : 'scale-100'}
196
- `}
197
- >
198
- {state === 'refreshing'
199
- ? (loadingIndicator || defaultLoadingIndicator)
200
- : (pullIndicator || defaultPullIndicator)
201
- }
202
- </div>
240
+ {getIndicatorContent()}
203
241
  </div>
204
242
 
205
243
  {/* Content wrapper */}
206
244
  <div
207
- className="transition-transform duration-200"
245
+ className="transition-transform duration-200 ease-out"
208
246
  style={{
209
247
  transform: `translateY(${pullDistance}px)`,
210
248
  }}
@@ -215,80 +253,4 @@ export default function PullToRefresh({
215
253
  );
216
254
  }
217
255
 
218
- /**
219
- * usePullToRefresh - Hook for custom pull-to-refresh implementations
220
- *
221
- * @example
222
- * ```tsx
223
- * const { pullDistance, isRefreshing, bind } = usePullToRefresh({
224
- * onRefresh: async () => {
225
- * await fetchData();
226
- * }
227
- * });
228
- *
229
- * return (
230
- * <div {...bind}>
231
- * {isRefreshing && <Spinner />}
232
- * {content}
233
- * </div>
234
- * );
235
- * ```
236
- */
237
- export function usePullToRefresh({
238
- onRefresh,
239
- pullThreshold = 80,
240
- maxPull = 120,
241
- disabled = false,
242
- }: {
243
- onRefresh: () => Promise<void>;
244
- pullThreshold?: number;
245
- maxPull?: number;
246
- disabled?: boolean;
247
- }) {
248
- const [pullDistance, setPullDistance] = useState(0);
249
- const [isRefreshing, setIsRefreshing] = useState(false);
250
- const startY = useRef(0);
251
-
252
- const handleTouchStart = useCallback((e: React.TouchEvent) => {
253
- if (disabled || isRefreshing) return;
254
- startY.current = e.touches[0].clientY;
255
- }, [disabled, isRefreshing]);
256
-
257
- const handleTouchMove = useCallback((e: React.TouchEvent) => {
258
- if (disabled || isRefreshing || startY.current === 0) return;
259
-
260
- const diff = e.touches[0].clientY - startY.current;
261
- if (diff > 0) {
262
- const adjustedPull = Math.min(diff * 0.5, maxPull);
263
- setPullDistance(adjustedPull);
264
- }
265
- }, [disabled, isRefreshing, maxPull]);
266
-
267
- const handleTouchEnd = useCallback(async () => {
268
- if (disabled || isRefreshing) return;
269
-
270
- if (pullDistance >= pullThreshold) {
271
- setIsRefreshing(true);
272
- try {
273
- await onRefresh();
274
- } finally {
275
- setIsRefreshing(false);
276
- }
277
- }
278
-
279
- setPullDistance(0);
280
- startY.current = 0;
281
- }, [disabled, isRefreshing, pullDistance, pullThreshold, onRefresh]);
282
-
283
- return {
284
- pullDistance,
285
- isRefreshing,
286
- isReady: pullDistance >= pullThreshold,
287
- progress: Math.min(pullDistance / pullThreshold, 1),
288
- bind: {
289
- onTouchStart: handleTouchStart,
290
- onTouchMove: handleTouchMove,
291
- onTouchEnd: handleTouchEnd,
292
- },
293
- };
294
- }
256
+ export default PullToRefresh;
@@ -3,7 +3,7 @@
3
3
 
4
4
  import React, { forwardRef } from 'react';
5
5
 
6
- type SpacingValue = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
6
+ type SpacingValue = 'none' | 'tight' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
7
7
 
8
8
  export interface StackProps extends React.HTMLAttributes<HTMLDivElement> {
9
9
  /** Content to stack */
@@ -31,6 +31,7 @@ export interface StackProps extends React.HTMLAttributes<HTMLDivElement> {
31
31
  *
32
32
  * Spacing scale (use either `spacing` or `gap` prop - they're aliases):
33
33
  * - none: 0
34
+ * - tight: 0.25rem (1) - for mobile-density layouts
34
35
  * - xs: 0.5rem (2)
35
36
  * - sm: 0.75rem (3)
36
37
  * - md: 1.5rem (6)
@@ -69,6 +70,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(({
69
70
  const spacingClasses = {
70
71
  vertical: {
71
72
  none: '',
73
+ tight: 'space-y-1', // 4px - for mobile-density layouts
72
74
  xs: 'space-y-2',
73
75
  sm: 'space-y-3',
74
76
  md: 'space-y-6',
@@ -77,6 +79,7 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>(({
77
79
  },
78
80
  horizontal: {
79
81
  none: '',
82
+ tight: 'space-x-1', // 4px - for mobile-density layouts
80
83
  xs: 'space-x-2',
81
84
  sm: 'space-x-3',
82
85
  md: 'space-x-6',