@papernote/ui 1.3.1 → 1.6.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.
- package/dist/components/ActionBar.d.ts +112 -0
- package/dist/components/ActionBar.d.ts.map +1 -0
- package/dist/components/BottomNavigation.d.ts +98 -0
- package/dist/components/BottomNavigation.d.ts.map +1 -0
- package/dist/components/Checkbox.d.ts +2 -0
- package/dist/components/Checkbox.d.ts.map +1 -1
- package/dist/components/CheckboxList.d.ts +81 -0
- package/dist/components/CheckboxList.d.ts.map +1 -0
- package/dist/components/Chip.d.ts +92 -1
- package/dist/components/Chip.d.ts.map +1 -1
- package/dist/components/ConfirmDialog.d.ts +43 -1
- package/dist/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/components/DataTable.d.ts +10 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/DataTableCardView.d.ts +99 -0
- package/dist/components/DataTableCardView.d.ts.map +1 -0
- package/dist/components/ExpandablePanel.d.ts +142 -0
- package/dist/components/ExpandablePanel.d.ts.map +1 -0
- package/dist/components/FloatingActionButton.d.ts +98 -0
- package/dist/components/FloatingActionButton.d.ts.map +1 -0
- package/dist/components/Input.d.ts +45 -1
- package/dist/components/Input.d.ts.map +1 -1
- package/dist/components/MobileHeader.d.ts +98 -0
- package/dist/components/MobileHeader.d.ts.map +1 -0
- package/dist/components/MobileLayout.d.ts +121 -0
- package/dist/components/MobileLayout.d.ts.map +1 -0
- package/dist/components/Modal.d.ts +78 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/PageHeader.d.ts +86 -0
- package/dist/components/PageHeader.d.ts.map +1 -0
- package/dist/components/PullToRefresh.d.ts +87 -0
- package/dist/components/PullToRefresh.d.ts.map +1 -0
- package/dist/components/QueryTransparency.d.ts +1 -1
- package/dist/components/QueryTransparency.d.ts.map +1 -1
- package/dist/components/SearchableList.d.ts +83 -0
- package/dist/components/SearchableList.d.ts.map +1 -0
- package/dist/components/Select.d.ts +16 -2
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/Sidebar.d.ts +40 -1
- package/dist/components/Sidebar.d.ts.map +1 -1
- package/dist/components/SwipeActions.d.ts +93 -0
- package/dist/components/SwipeActions.d.ts.map +1 -0
- package/dist/components/Switch.d.ts +1 -0
- package/dist/components/Switch.d.ts.map +1 -1
- package/dist/components/Textarea.d.ts +13 -0
- package/dist/components/Textarea.d.ts.map +1 -1
- package/dist/components/index.d.ts +31 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/context/MobileContext.d.ts +168 -0
- package/dist/context/MobileContext.d.ts.map +1 -0
- package/dist/hooks/useResponsive.d.ts +158 -0
- package/dist/hooks/useResponsive.d.ts.map +1 -0
- package/dist/index.d.ts +1871 -51
- package/dist/index.esm.js +3025 -196
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +3063 -194
- package/dist/index.js.map +1 -1
- package/dist/styles.css +434 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/ActionBar.stories.tsx +246 -0
- package/src/components/ActionBar.tsx +242 -0
- package/src/components/BottomNavigation.stories.tsx +142 -0
- package/src/components/BottomNavigation.tsx +225 -0
- package/src/components/Checkbox.stories.tsx +162 -0
- package/src/components/Checkbox.tsx +22 -6
- package/src/components/CheckboxList.stories.tsx +311 -0
- package/src/components/CheckboxList.tsx +433 -0
- package/src/components/Chip.stories.tsx +389 -0
- package/src/components/Chip.tsx +182 -3
- package/src/components/ConfirmDialog.tsx +56 -4
- package/src/components/DataTable.tsx +60 -1
- package/src/components/DataTableCardView.stories.tsx +307 -0
- package/src/components/DataTableCardView.tsx +419 -0
- package/src/components/ExpandablePanel.stories.tsx +620 -0
- package/src/components/ExpandablePanel.tsx +383 -0
- package/src/components/FloatingActionButton.stories.tsx +197 -0
- package/src/components/FloatingActionButton.tsx +301 -0
- package/src/components/Grid.stories.tsx +16 -16
- package/src/components/Input.stories.tsx +214 -0
- package/src/components/Input.tsx +81 -4
- package/src/components/MobileHeader.stories.tsx +205 -0
- package/src/components/MobileHeader.tsx +233 -0
- package/src/components/MobileLayout.stories.tsx +338 -0
- package/src/components/MobileLayout.tsx +313 -0
- package/src/components/Modal.stories.tsx +388 -0
- package/src/components/Modal.tsx +122 -4
- package/src/components/PageHeader.stories.tsx +198 -0
- package/src/components/PageHeader.tsx +217 -0
- package/src/components/PullToRefresh.stories.tsx +321 -0
- package/src/components/PullToRefresh.tsx +294 -0
- package/src/components/QueryTransparency.tsx +1 -1
- package/src/components/SearchableList.stories.tsx +437 -0
- package/src/components/SearchableList.tsx +326 -0
- package/src/components/Select.stories.tsx +190 -0
- package/src/components/Select.tsx +353 -137
- package/src/components/Sidebar.tsx +193 -10
- package/src/components/SwipeActions.stories.tsx +327 -0
- package/src/components/SwipeActions.tsx +387 -0
- package/src/components/Switch.stories.tsx +158 -0
- package/src/components/Switch.tsx +12 -3
- package/src/components/Textarea.tsx +31 -1
- package/src/components/index.ts +69 -3
- package/src/context/MobileContext.tsx +296 -0
- package/src/hooks/useResponsive.ts +360 -0
- package/src/types/index.ts +4 -0
- package/tailwind.config.js +56 -1
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// SwipeActions - Touch gesture-based swipe actions component
|
|
2
|
+
// Provides left/right swipe actions commonly used in mobile list items
|
|
3
|
+
|
|
4
|
+
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single swipe action configuration
|
|
8
|
+
*/
|
|
9
|
+
export interface SwipeAction {
|
|
10
|
+
/** Unique identifier */
|
|
11
|
+
id: string;
|
|
12
|
+
/** Action label (shown on the button) */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Icon to display */
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
/** Background color class (Tailwind) */
|
|
17
|
+
color?: 'primary' | 'success' | 'warning' | 'error' | 'default';
|
|
18
|
+
/** Click handler */
|
|
19
|
+
onClick: () => void;
|
|
20
|
+
/** Whether this is the primary action (full swipe triggers it) */
|
|
21
|
+
primary?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* SwipeActions component props
|
|
26
|
+
*/
|
|
27
|
+
export interface SwipeActionsProps {
|
|
28
|
+
/** Content to wrap with swipe actions */
|
|
29
|
+
children: React.ReactNode;
|
|
30
|
+
/** Actions shown when swiping left (appears on right side) */
|
|
31
|
+
leftActions?: SwipeAction[];
|
|
32
|
+
/** Actions shown when swiping right (appears on left side) */
|
|
33
|
+
rightActions?: SwipeAction[];
|
|
34
|
+
/** Swipe threshold in pixels to reveal actions (default: 80) */
|
|
35
|
+
threshold?: number;
|
|
36
|
+
/** Full swipe threshold percentage to trigger primary action (default: 0.5) */
|
|
37
|
+
fullSwipeThreshold?: number;
|
|
38
|
+
/** Enable full swipe to trigger primary action */
|
|
39
|
+
fullSwipe?: boolean;
|
|
40
|
+
/** Disable swipe gestures */
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** Callback when actions are revealed/hidden */
|
|
43
|
+
onSwipeChange?: (direction: 'left' | 'right' | null) => void;
|
|
44
|
+
/** Additional CSS classes */
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Color mapping for action buttons
|
|
49
|
+
const colorClasses = {
|
|
50
|
+
primary: 'bg-accent-500 text-white',
|
|
51
|
+
success: 'bg-success-500 text-white',
|
|
52
|
+
warning: 'bg-warning-500 text-white',
|
|
53
|
+
error: 'bg-error-500 text-white',
|
|
54
|
+
default: 'bg-paper-500 text-white',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* SwipeActions - Touch-based swipe actions for list items
|
|
59
|
+
*
|
|
60
|
+
* Wraps any content with swipe-to-reveal actions, commonly used in mobile
|
|
61
|
+
* list items for quick actions like delete, archive, edit, etc.
|
|
62
|
+
*
|
|
63
|
+
* Features:
|
|
64
|
+
* - Left and right swipe actions
|
|
65
|
+
* - Full swipe to trigger primary action
|
|
66
|
+
* - Spring-back animation
|
|
67
|
+
* - Touch and mouse support
|
|
68
|
+
* - Customizable thresholds
|
|
69
|
+
*
|
|
70
|
+
* @example Basic delete action
|
|
71
|
+
* ```tsx
|
|
72
|
+
* <SwipeActions
|
|
73
|
+
* leftActions={[
|
|
74
|
+
* {
|
|
75
|
+
* id: 'delete',
|
|
76
|
+
* label: 'Delete',
|
|
77
|
+
* icon: <Trash className="h-5 w-5" />,
|
|
78
|
+
* color: 'error',
|
|
79
|
+
* onClick: () => handleDelete(item),
|
|
80
|
+
* primary: true,
|
|
81
|
+
* },
|
|
82
|
+
* ]}
|
|
83
|
+
* >
|
|
84
|
+
* <div className="p-4 bg-white">
|
|
85
|
+
* List item content
|
|
86
|
+
* </div>
|
|
87
|
+
* </SwipeActions>
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* @example Multiple actions on both sides
|
|
91
|
+
* ```tsx
|
|
92
|
+
* <SwipeActions
|
|
93
|
+
* leftActions={[
|
|
94
|
+
* { id: 'delete', label: 'Delete', icon: <Trash />, color: 'error', onClick: handleDelete },
|
|
95
|
+
* { id: 'archive', label: 'Archive', icon: <Archive />, color: 'warning', onClick: handleArchive },
|
|
96
|
+
* ]}
|
|
97
|
+
* rightActions={[
|
|
98
|
+
* { id: 'edit', label: 'Edit', icon: <Edit />, color: 'primary', onClick: handleEdit },
|
|
99
|
+
* ]}
|
|
100
|
+
* fullSwipe
|
|
101
|
+
* >
|
|
102
|
+
* <ListItem />
|
|
103
|
+
* </SwipeActions>
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function SwipeActions({
|
|
107
|
+
children,
|
|
108
|
+
leftActions = [],
|
|
109
|
+
rightActions = [],
|
|
110
|
+
threshold = 80,
|
|
111
|
+
fullSwipeThreshold = 0.5,
|
|
112
|
+
fullSwipe = false,
|
|
113
|
+
disabled = false,
|
|
114
|
+
onSwipeChange,
|
|
115
|
+
className = '',
|
|
116
|
+
}: SwipeActionsProps) {
|
|
117
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
118
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
119
|
+
|
|
120
|
+
// Swipe state
|
|
121
|
+
const [translateX, setTranslateX] = useState(0);
|
|
122
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
123
|
+
const [activeDirection, setActiveDirection] = useState<'left' | 'right' | null>(null);
|
|
124
|
+
|
|
125
|
+
// Touch/mouse tracking
|
|
126
|
+
const startX = useRef(0);
|
|
127
|
+
const currentX = useRef(0);
|
|
128
|
+
const startTime = useRef(0);
|
|
129
|
+
|
|
130
|
+
// Calculate action widths
|
|
131
|
+
const leftActionsWidth = leftActions.length * 72; // 72px per action
|
|
132
|
+
const rightActionsWidth = rightActions.length * 72;
|
|
133
|
+
|
|
134
|
+
// Reset position
|
|
135
|
+
const resetPosition = useCallback(() => {
|
|
136
|
+
setTranslateX(0);
|
|
137
|
+
setActiveDirection(null);
|
|
138
|
+
onSwipeChange?.(null);
|
|
139
|
+
}, [onSwipeChange]);
|
|
140
|
+
|
|
141
|
+
// Handle touch/mouse start
|
|
142
|
+
const handleStart = useCallback((clientX: number) => {
|
|
143
|
+
if (disabled) return;
|
|
144
|
+
|
|
145
|
+
startX.current = clientX;
|
|
146
|
+
currentX.current = clientX;
|
|
147
|
+
startTime.current = Date.now();
|
|
148
|
+
setIsDragging(true);
|
|
149
|
+
}, [disabled]);
|
|
150
|
+
|
|
151
|
+
// Handle touch/mouse move
|
|
152
|
+
const handleMove = useCallback((clientX: number) => {
|
|
153
|
+
if (!isDragging || disabled) return;
|
|
154
|
+
|
|
155
|
+
const deltaX = clientX - startX.current;
|
|
156
|
+
currentX.current = clientX;
|
|
157
|
+
|
|
158
|
+
// Determine direction and apply resistance at boundaries
|
|
159
|
+
let newTranslateX = deltaX;
|
|
160
|
+
|
|
161
|
+
// Swiping left (reveals left actions on right side)
|
|
162
|
+
if (deltaX < 0) {
|
|
163
|
+
if (leftActions.length === 0) {
|
|
164
|
+
newTranslateX = deltaX * 0.2; // Heavy resistance if no actions
|
|
165
|
+
} else {
|
|
166
|
+
const maxSwipe = fullSwipe
|
|
167
|
+
? -(containerRef.current?.offsetWidth || 300)
|
|
168
|
+
: -leftActionsWidth;
|
|
169
|
+
newTranslateX = Math.max(maxSwipe, deltaX);
|
|
170
|
+
|
|
171
|
+
// Apply resistance past the action buttons
|
|
172
|
+
if (newTranslateX < -leftActionsWidth) {
|
|
173
|
+
const overSwipe = newTranslateX + leftActionsWidth;
|
|
174
|
+
newTranslateX = -leftActionsWidth + overSwipe * 0.3;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
setActiveDirection('left');
|
|
178
|
+
onSwipeChange?.('left');
|
|
179
|
+
}
|
|
180
|
+
// Swiping right (reveals right actions on left side)
|
|
181
|
+
else if (deltaX > 0) {
|
|
182
|
+
if (rightActions.length === 0) {
|
|
183
|
+
newTranslateX = deltaX * 0.2; // Heavy resistance if no actions
|
|
184
|
+
} else {
|
|
185
|
+
const maxSwipe = fullSwipe
|
|
186
|
+
? (containerRef.current?.offsetWidth || 300)
|
|
187
|
+
: rightActionsWidth;
|
|
188
|
+
newTranslateX = Math.min(maxSwipe, deltaX);
|
|
189
|
+
|
|
190
|
+
// Apply resistance past the action buttons
|
|
191
|
+
if (newTranslateX > rightActionsWidth) {
|
|
192
|
+
const overSwipe = newTranslateX - rightActionsWidth;
|
|
193
|
+
newTranslateX = rightActionsWidth + overSwipe * 0.3;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
setActiveDirection('right');
|
|
197
|
+
onSwipeChange?.('right');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setTranslateX(newTranslateX);
|
|
201
|
+
}, [isDragging, disabled, leftActions.length, rightActions.length, leftActionsWidth, rightActionsWidth, fullSwipe, onSwipeChange]);
|
|
202
|
+
|
|
203
|
+
// Handle touch/mouse end
|
|
204
|
+
const handleEnd = useCallback(() => {
|
|
205
|
+
if (!isDragging) return;
|
|
206
|
+
|
|
207
|
+
setIsDragging(false);
|
|
208
|
+
|
|
209
|
+
const deltaX = currentX.current - startX.current;
|
|
210
|
+
const velocity = Math.abs(deltaX) / (Date.now() - startTime.current);
|
|
211
|
+
const containerWidth = containerRef.current?.offsetWidth || 300;
|
|
212
|
+
|
|
213
|
+
// Check for full swipe trigger
|
|
214
|
+
if (fullSwipe) {
|
|
215
|
+
const swipePercentage = Math.abs(translateX) / containerWidth;
|
|
216
|
+
|
|
217
|
+
if (swipePercentage >= fullSwipeThreshold || velocity > 0.5) {
|
|
218
|
+
// Find primary action and trigger it
|
|
219
|
+
if (translateX < 0 && leftActions.length > 0) {
|
|
220
|
+
const primaryAction = leftActions.find(a => a.primary) || leftActions[0];
|
|
221
|
+
primaryAction.onClick();
|
|
222
|
+
resetPosition();
|
|
223
|
+
return;
|
|
224
|
+
} else if (translateX > 0 && rightActions.length > 0) {
|
|
225
|
+
const primaryAction = rightActions.find(a => a.primary) || rightActions[0];
|
|
226
|
+
primaryAction.onClick();
|
|
227
|
+
resetPosition();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Snap to open or closed position
|
|
234
|
+
if (Math.abs(translateX) >= threshold || velocity > 0.3) {
|
|
235
|
+
// Snap open
|
|
236
|
+
if (translateX < 0 && leftActions.length > 0) {
|
|
237
|
+
setTranslateX(-leftActionsWidth);
|
|
238
|
+
setActiveDirection('left');
|
|
239
|
+
onSwipeChange?.('left');
|
|
240
|
+
} else if (translateX > 0 && rightActions.length > 0) {
|
|
241
|
+
setTranslateX(rightActionsWidth);
|
|
242
|
+
setActiveDirection('right');
|
|
243
|
+
onSwipeChange?.('right');
|
|
244
|
+
} else {
|
|
245
|
+
resetPosition();
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
// Snap closed
|
|
249
|
+
resetPosition();
|
|
250
|
+
}
|
|
251
|
+
}, [isDragging, translateX, threshold, fullSwipe, fullSwipeThreshold, leftActions, rightActions, leftActionsWidth, rightActionsWidth, resetPosition, onSwipeChange]);
|
|
252
|
+
|
|
253
|
+
// Touch event handlers
|
|
254
|
+
const handleTouchStart = (e: React.TouchEvent) => {
|
|
255
|
+
handleStart(e.touches[0].clientX);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const handleTouchMove = (e: React.TouchEvent) => {
|
|
259
|
+
handleMove(e.touches[0].clientX);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handleTouchEnd = () => {
|
|
263
|
+
handleEnd();
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// Mouse event handlers (for testing/desktop)
|
|
267
|
+
const handleMouseDown = (e: React.MouseEvent) => {
|
|
268
|
+
handleStart(e.clientX);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const handleMouseMove = (e: React.MouseEvent) => {
|
|
272
|
+
handleMove(e.clientX);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleMouseUp = () => {
|
|
276
|
+
handleEnd();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Close on outside click
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (activeDirection === null) return;
|
|
282
|
+
|
|
283
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
284
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
285
|
+
resetPosition();
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
290
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
291
|
+
}, [activeDirection, resetPosition]);
|
|
292
|
+
|
|
293
|
+
// Handle mouse leave during drag
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!isDragging) return;
|
|
296
|
+
|
|
297
|
+
const handleMouseLeave = () => {
|
|
298
|
+
handleEnd();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
document.addEventListener('mouseup', handleMouseLeave);
|
|
302
|
+
return () => document.removeEventListener('mouseup', handleMouseLeave);
|
|
303
|
+
}, [isDragging, handleEnd]);
|
|
304
|
+
|
|
305
|
+
// Render action button
|
|
306
|
+
const renderActionButton = (action: SwipeAction) => {
|
|
307
|
+
const colorClass = colorClasses[action.color || 'default'];
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<button
|
|
311
|
+
key={action.id}
|
|
312
|
+
onClick={(e) => {
|
|
313
|
+
e.stopPropagation();
|
|
314
|
+
action.onClick();
|
|
315
|
+
resetPosition();
|
|
316
|
+
}}
|
|
317
|
+
className={`
|
|
318
|
+
flex flex-col items-center justify-center
|
|
319
|
+
w-18 h-full min-w-[72px]
|
|
320
|
+
${colorClass}
|
|
321
|
+
transition-transform duration-150
|
|
322
|
+
`}
|
|
323
|
+
style={{
|
|
324
|
+
transform: isDragging ? 'scale(1)' : 'scale(1)',
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{action.icon && (
|
|
328
|
+
<div className="mb-1">
|
|
329
|
+
{action.icon}
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
<span className="text-xs font-medium">{action.label}</span>
|
|
333
|
+
</button>
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div
|
|
339
|
+
ref={containerRef}
|
|
340
|
+
className={`relative overflow-hidden ${className}`}
|
|
341
|
+
onTouchStart={handleTouchStart}
|
|
342
|
+
onTouchMove={handleTouchMove}
|
|
343
|
+
onTouchEnd={handleTouchEnd}
|
|
344
|
+
onMouseDown={handleMouseDown}
|
|
345
|
+
onMouseMove={isDragging ? handleMouseMove : undefined}
|
|
346
|
+
onMouseUp={handleMouseUp}
|
|
347
|
+
onMouseLeave={isDragging ? handleEnd : undefined}
|
|
348
|
+
>
|
|
349
|
+
{/* Right actions (revealed when swiping right) */}
|
|
350
|
+
{rightActions.length > 0 && (
|
|
351
|
+
<div
|
|
352
|
+
className="absolute left-0 top-0 bottom-0 flex"
|
|
353
|
+
style={{ width: rightActionsWidth }}
|
|
354
|
+
>
|
|
355
|
+
{rightActions.map((action) => renderActionButton(action))}
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
|
|
359
|
+
{/* Left actions (revealed when swiping left) */}
|
|
360
|
+
{leftActions.length > 0 && (
|
|
361
|
+
<div
|
|
362
|
+
className="absolute right-0 top-0 bottom-0 flex"
|
|
363
|
+
style={{ width: leftActionsWidth }}
|
|
364
|
+
>
|
|
365
|
+
{leftActions.map((action) => renderActionButton(action))}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
|
|
369
|
+
{/* Main content */}
|
|
370
|
+
<div
|
|
371
|
+
ref={contentRef}
|
|
372
|
+
className={`
|
|
373
|
+
relative bg-white
|
|
374
|
+
${isDragging ? '' : 'transition-transform duration-200 ease-out'}
|
|
375
|
+
`}
|
|
376
|
+
style={{
|
|
377
|
+
transform: `translateX(${translateX}px)`,
|
|
378
|
+
touchAction: 'pan-y', // Allow vertical scrolling
|
|
379
|
+
}}
|
|
380
|
+
>
|
|
381
|
+
{children}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export default SwipeActions;
|
|
@@ -266,3 +266,161 @@ export const SettingsGroup: Story = {
|
|
|
266
266
|
);
|
|
267
267
|
},
|
|
268
268
|
};
|
|
269
|
+
|
|
270
|
+
// Mobile-optimized stories
|
|
271
|
+
export const MobileLargeTouch: Story = {
|
|
272
|
+
parameters: {
|
|
273
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
274
|
+
docs: {
|
|
275
|
+
description: {
|
|
276
|
+
story: 'Large size (lg) switch provides larger touch target for mobile devices. On mobile, md automatically upgrades to lg.',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
render: () => {
|
|
281
|
+
const [checked, setChecked] = useState(false);
|
|
282
|
+
return (
|
|
283
|
+
<Switch
|
|
284
|
+
checked={checked}
|
|
285
|
+
onChange={setChecked}
|
|
286
|
+
size="lg"
|
|
287
|
+
label="Touch-friendly switch"
|
|
288
|
+
/>
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export const MobileSettingsPage: Story = {
|
|
294
|
+
parameters: {
|
|
295
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
296
|
+
docs: {
|
|
297
|
+
description: {
|
|
298
|
+
story: 'Mobile settings page with large switches optimized for touch interaction.',
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
render: () => {
|
|
303
|
+
const [settings, setSettings] = useState({
|
|
304
|
+
darkMode: false,
|
|
305
|
+
notifications: true,
|
|
306
|
+
sounds: true,
|
|
307
|
+
haptics: false,
|
|
308
|
+
location: true,
|
|
309
|
+
analytics: false,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', padding: '1rem' }}>
|
|
314
|
+
<h2 style={{ fontSize: '1.25rem', fontWeight: 600, marginBottom: '1.5rem' }}>Settings</h2>
|
|
315
|
+
|
|
316
|
+
<div style={{ borderBottom: '1px solid #e5e5e5', paddingBottom: '0.5rem', marginBottom: '0.75rem' }}>
|
|
317
|
+
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: '#666', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
318
|
+
Appearance
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
323
|
+
<div>
|
|
324
|
+
<div style={{ fontWeight: 500 }}>Dark Mode</div>
|
|
325
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Use dark color scheme</div>
|
|
326
|
+
</div>
|
|
327
|
+
<Switch checked={settings.darkMode} onChange={(v) => setSettings({...settings, darkMode: v})} size="lg" />
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div style={{ borderBottom: '1px solid #e5e5e5', paddingBottom: '0.5rem', marginBottom: '0.75rem', marginTop: '1rem' }}>
|
|
331
|
+
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: '#666', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
332
|
+
Notifications
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
337
|
+
<div>
|
|
338
|
+
<div style={{ fontWeight: 500 }}>Push Notifications</div>
|
|
339
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Enable push alerts</div>
|
|
340
|
+
</div>
|
|
341
|
+
<Switch checked={settings.notifications} onChange={(v) => setSettings({...settings, notifications: v})} size="lg" />
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
345
|
+
<div>
|
|
346
|
+
<div style={{ fontWeight: 500 }}>Sound Effects</div>
|
|
347
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Play sounds for actions</div>
|
|
348
|
+
</div>
|
|
349
|
+
<Switch checked={settings.sounds} onChange={(v) => setSettings({...settings, sounds: v})} size="lg" />
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
353
|
+
<div>
|
|
354
|
+
<div style={{ fontWeight: 500 }}>Haptic Feedback</div>
|
|
355
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Vibration feedback</div>
|
|
356
|
+
</div>
|
|
357
|
+
<Switch checked={settings.haptics} onChange={(v) => setSettings({...settings, haptics: v})} size="lg" />
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div style={{ borderBottom: '1px solid #e5e5e5', paddingBottom: '0.5rem', marginBottom: '0.75rem', marginTop: '1rem' }}>
|
|
361
|
+
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: '#666', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
362
|
+
Privacy
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
367
|
+
<div>
|
|
368
|
+
<div style={{ fontWeight: 500 }}>Location Services</div>
|
|
369
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Share your location</div>
|
|
370
|
+
</div>
|
|
371
|
+
<Switch checked={settings.location} onChange={(v) => setSettings({...settings, location: v})} size="lg" />
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
375
|
+
<div>
|
|
376
|
+
<div style={{ fontWeight: 500 }}>Analytics</div>
|
|
377
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Share usage data</div>
|
|
378
|
+
</div>
|
|
379
|
+
<Switch checked={settings.analytics} onChange={(v) => setSettings({...settings, analytics: v})} size="lg" />
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export const MobileAsyncToggle: Story = {
|
|
387
|
+
parameters: {
|
|
388
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
389
|
+
docs: {
|
|
390
|
+
description: {
|
|
391
|
+
story: 'Async toggle with loading state on mobile, showing spinner during API call.',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
render: () => {
|
|
396
|
+
const [enabled, setEnabled] = useState(false);
|
|
397
|
+
const [loading, setLoading] = useState(false);
|
|
398
|
+
|
|
399
|
+
const handleChange = async (newValue: boolean) => {
|
|
400
|
+
setLoading(true);
|
|
401
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
402
|
+
setEnabled(newValue);
|
|
403
|
+
setLoading(false);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div style={{ padding: '1rem' }}>
|
|
408
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem 0' }}>
|
|
409
|
+
<div>
|
|
410
|
+
<div style={{ fontWeight: 500 }}>Premium Features</div>
|
|
411
|
+
<div style={{ fontSize: '0.75rem', color: '#666' }}>Enable premium subscription</div>
|
|
412
|
+
</div>
|
|
413
|
+
<Switch
|
|
414
|
+
checked={enabled}
|
|
415
|
+
onChange={handleChange}
|
|
416
|
+
loading={loading}
|
|
417
|
+
size="lg"
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
<p style={{ fontSize: '0.75rem', color: '#999', marginTop: '0.5rem' }}>
|
|
421
|
+
Toggle shows loading spinner during async operations
|
|
422
|
+
</p>
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
},
|
|
426
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { forwardRef, useId } from 'react';
|
|
2
|
+
import { useIsMobile } from '../hooks/useResponsive';
|
|
2
3
|
import { Loader2 } from 'lucide-react';
|
|
3
4
|
|
|
4
5
|
export interface SwitchProps {
|
|
@@ -7,6 +8,7 @@ export interface SwitchProps {
|
|
|
7
8
|
label?: string;
|
|
8
9
|
description?: string;
|
|
9
10
|
disabled?: boolean;
|
|
11
|
+
/** Size variant - 'lg' provides better touch targets. On mobile, 'md' auto-upgrades to 'lg'. */
|
|
10
12
|
size?: 'sm' | 'md' | 'lg';
|
|
11
13
|
/** Show loading spinner (disables interaction) */
|
|
12
14
|
loading?: boolean;
|
|
@@ -26,6 +28,10 @@ const Switch = forwardRef<HTMLInputElement, SwitchProps>(({
|
|
|
26
28
|
const labelId = label ? `${switchId}-label` : undefined;
|
|
27
29
|
const descId = description ? `${switchId}-desc` : undefined;
|
|
28
30
|
|
|
31
|
+
// Auto-size for mobile
|
|
32
|
+
const isMobile = useIsMobile();
|
|
33
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
34
|
+
|
|
29
35
|
const sizeStyles = {
|
|
30
36
|
sm: {
|
|
31
37
|
switch: 'w-9 h-5',
|
|
@@ -47,7 +53,7 @@ const Switch = forwardRef<HTMLInputElement, SwitchProps>(({
|
|
|
47
53
|
},
|
|
48
54
|
};
|
|
49
55
|
|
|
50
|
-
const styles = sizeStyles[
|
|
56
|
+
const styles = sizeStyles[effectiveSize];
|
|
51
57
|
const isDisabled = disabled || loading;
|
|
52
58
|
|
|
53
59
|
const handleChange = () => {
|
|
@@ -56,8 +62,11 @@ const Switch = forwardRef<HTMLInputElement, SwitchProps>(({
|
|
|
56
62
|
}
|
|
57
63
|
};
|
|
58
64
|
|
|
65
|
+
// Touch target padding for mobile
|
|
66
|
+
const touchTargetClass = effectiveSize === 'lg' ? 'min-h-touch py-1' : '';
|
|
67
|
+
|
|
59
68
|
return (
|
|
60
|
-
<label htmlFor={switchId} className={`flex items-center gap-3 ${isDisabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}>
|
|
69
|
+
<label htmlFor={switchId} className={`flex items-center gap-3 ${touchTargetClass} ${isDisabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}>
|
|
61
70
|
{/* Switch */}
|
|
62
71
|
<div className="relative inline-block flex-shrink-0">
|
|
63
72
|
<input
|
|
@@ -98,7 +107,7 @@ const Switch = forwardRef<HTMLInputElement, SwitchProps>(({
|
|
|
98
107
|
{/* Label */}
|
|
99
108
|
{(label || description) && (
|
|
100
109
|
<div className="flex-1">
|
|
101
|
-
{label && <p id={labelId} className=
|
|
110
|
+
{label && <p id={labelId} className={`${effectiveSize === 'lg' ? 'text-base' : 'text-sm'} font-medium text-ink-900`}>{label}</p>}
|
|
102
111
|
{description && <p id={descId} className="text-xs text-ink-600 mt-0.5">{description}</p>}
|
|
103
112
|
</div>
|
|
104
113
|
)}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { forwardRef, useEffect, useRef } from 'react';
|
|
2
|
+
import { useIsMobile } from '../hooks/useResponsive';
|
|
2
3
|
import { AlertCircle, CheckCircle, AlertTriangle, Loader2 } from 'lucide-react';
|
|
3
4
|
|
|
4
5
|
export type ValidationState = 'error' | 'success' | 'warning' | null;
|
|
@@ -20,8 +21,30 @@ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextArea
|
|
|
20
21
|
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
|
|
21
22
|
/** Show loading spinner (for async operations like auto-save) */
|
|
22
23
|
loading?: boolean;
|
|
24
|
+
|
|
25
|
+
// Mobile optimization props
|
|
26
|
+
/**
|
|
27
|
+
* Size variant - 'md' is default, 'lg' provides larger touch-friendly text and padding.
|
|
28
|
+
* On mobile, 'md' is automatically upgraded to 'lg' for better touch targets.
|
|
29
|
+
*/
|
|
30
|
+
size?: 'sm' | 'md' | 'lg';
|
|
31
|
+
/**
|
|
32
|
+
* Enter key hint for mobile keyboards.
|
|
33
|
+
* 'enter' - Standard enter key (newline)
|
|
34
|
+
* 'done' - Done action
|
|
35
|
+
* 'go' - Go/navigate action
|
|
36
|
+
* 'send' - Send action
|
|
37
|
+
*/
|
|
38
|
+
enterKeyHint?: 'enter' | 'done' | 'go' | 'send';
|
|
23
39
|
}
|
|
24
40
|
|
|
41
|
+
// Size classes for textarea
|
|
42
|
+
const sizeClasses = {
|
|
43
|
+
sm: 'px-3 py-2 text-sm',
|
|
44
|
+
md: 'px-4 py-3 text-sm',
|
|
45
|
+
lg: 'px-4 py-3.5 text-base', // Larger padding and text for mobile
|
|
46
|
+
};
|
|
47
|
+
|
|
25
48
|
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
26
49
|
(
|
|
27
50
|
{
|
|
@@ -36,6 +59,8 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
36
59
|
maxRows = 10,
|
|
37
60
|
resize = 'vertical',
|
|
38
61
|
loading = false,
|
|
62
|
+
size = 'md',
|
|
63
|
+
enterKeyHint,
|
|
39
64
|
className = '',
|
|
40
65
|
id,
|
|
41
66
|
value,
|
|
@@ -44,6 +69,9 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
44
69
|
},
|
|
45
70
|
ref
|
|
46
71
|
) => {
|
|
72
|
+
// Detect mobile and auto-size
|
|
73
|
+
const isMobile = useIsMobile();
|
|
74
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
47
75
|
const textareaId = id || `textarea-${Math.random().toString(36).substring(2, 9)}`;
|
|
48
76
|
const currentLength = typeof value === 'string' ? value.length : 0;
|
|
49
77
|
const internalRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -147,11 +175,13 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
147
175
|
value={value}
|
|
148
176
|
maxLength={maxLength}
|
|
149
177
|
rows={autoExpand ? minRows : rows}
|
|
178
|
+
enterKeyHint={enterKeyHint}
|
|
150
179
|
className={`
|
|
151
|
-
block w-full
|
|
180
|
+
block w-full border rounded-lg text-ink-800 placeholder-ink-400
|
|
152
181
|
bg-white bg-subtle-grain transition-all duration-200
|
|
153
182
|
focus:outline-none focus:ring-2 ${getResizeClass()}
|
|
154
183
|
disabled:bg-paper-100 disabled:text-ink-400 disabled:cursor-not-allowed disabled:opacity-60
|
|
184
|
+
${sizeClasses[effectiveSize]}
|
|
155
185
|
${getValidationClasses()}
|
|
156
186
|
${loading ? 'pr-10' : ''}
|
|
157
187
|
${className}
|