@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.
Files changed (108) hide show
  1. package/dist/components/ActionBar.d.ts +112 -0
  2. package/dist/components/ActionBar.d.ts.map +1 -0
  3. package/dist/components/BottomNavigation.d.ts +98 -0
  4. package/dist/components/BottomNavigation.d.ts.map +1 -0
  5. package/dist/components/Checkbox.d.ts +2 -0
  6. package/dist/components/Checkbox.d.ts.map +1 -1
  7. package/dist/components/CheckboxList.d.ts +81 -0
  8. package/dist/components/CheckboxList.d.ts.map +1 -0
  9. package/dist/components/Chip.d.ts +92 -1
  10. package/dist/components/Chip.d.ts.map +1 -1
  11. package/dist/components/ConfirmDialog.d.ts +43 -1
  12. package/dist/components/ConfirmDialog.d.ts.map +1 -1
  13. package/dist/components/DataTable.d.ts +10 -1
  14. package/dist/components/DataTable.d.ts.map +1 -1
  15. package/dist/components/DataTableCardView.d.ts +99 -0
  16. package/dist/components/DataTableCardView.d.ts.map +1 -0
  17. package/dist/components/ExpandablePanel.d.ts +142 -0
  18. package/dist/components/ExpandablePanel.d.ts.map +1 -0
  19. package/dist/components/FloatingActionButton.d.ts +98 -0
  20. package/dist/components/FloatingActionButton.d.ts.map +1 -0
  21. package/dist/components/Input.d.ts +45 -1
  22. package/dist/components/Input.d.ts.map +1 -1
  23. package/dist/components/MobileHeader.d.ts +98 -0
  24. package/dist/components/MobileHeader.d.ts.map +1 -0
  25. package/dist/components/MobileLayout.d.ts +121 -0
  26. package/dist/components/MobileLayout.d.ts.map +1 -0
  27. package/dist/components/Modal.d.ts +78 -1
  28. package/dist/components/Modal.d.ts.map +1 -1
  29. package/dist/components/PageHeader.d.ts +86 -0
  30. package/dist/components/PageHeader.d.ts.map +1 -0
  31. package/dist/components/PullToRefresh.d.ts +87 -0
  32. package/dist/components/PullToRefresh.d.ts.map +1 -0
  33. package/dist/components/QueryTransparency.d.ts +1 -1
  34. package/dist/components/QueryTransparency.d.ts.map +1 -1
  35. package/dist/components/SearchableList.d.ts +83 -0
  36. package/dist/components/SearchableList.d.ts.map +1 -0
  37. package/dist/components/Select.d.ts +16 -2
  38. package/dist/components/Select.d.ts.map +1 -1
  39. package/dist/components/Sidebar.d.ts +40 -1
  40. package/dist/components/Sidebar.d.ts.map +1 -1
  41. package/dist/components/SwipeActions.d.ts +93 -0
  42. package/dist/components/SwipeActions.d.ts.map +1 -0
  43. package/dist/components/Switch.d.ts +1 -0
  44. package/dist/components/Switch.d.ts.map +1 -1
  45. package/dist/components/Textarea.d.ts +13 -0
  46. package/dist/components/Textarea.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +31 -3
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/context/MobileContext.d.ts +168 -0
  50. package/dist/context/MobileContext.d.ts.map +1 -0
  51. package/dist/hooks/useResponsive.d.ts +158 -0
  52. package/dist/hooks/useResponsive.d.ts.map +1 -0
  53. package/dist/index.d.ts +1871 -51
  54. package/dist/index.esm.js +3025 -196
  55. package/dist/index.esm.js.map +1 -1
  56. package/dist/index.js +3063 -194
  57. package/dist/index.js.map +1 -1
  58. package/dist/styles.css +434 -1
  59. package/dist/types/index.d.ts +2 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/package.json +1 -1
  62. package/src/components/ActionBar.stories.tsx +246 -0
  63. package/src/components/ActionBar.tsx +242 -0
  64. package/src/components/BottomNavigation.stories.tsx +142 -0
  65. package/src/components/BottomNavigation.tsx +225 -0
  66. package/src/components/Checkbox.stories.tsx +162 -0
  67. package/src/components/Checkbox.tsx +22 -6
  68. package/src/components/CheckboxList.stories.tsx +311 -0
  69. package/src/components/CheckboxList.tsx +433 -0
  70. package/src/components/Chip.stories.tsx +389 -0
  71. package/src/components/Chip.tsx +182 -3
  72. package/src/components/ConfirmDialog.tsx +56 -4
  73. package/src/components/DataTable.tsx +60 -1
  74. package/src/components/DataTableCardView.stories.tsx +307 -0
  75. package/src/components/DataTableCardView.tsx +419 -0
  76. package/src/components/ExpandablePanel.stories.tsx +620 -0
  77. package/src/components/ExpandablePanel.tsx +383 -0
  78. package/src/components/FloatingActionButton.stories.tsx +197 -0
  79. package/src/components/FloatingActionButton.tsx +301 -0
  80. package/src/components/Grid.stories.tsx +16 -16
  81. package/src/components/Input.stories.tsx +214 -0
  82. package/src/components/Input.tsx +81 -4
  83. package/src/components/MobileHeader.stories.tsx +205 -0
  84. package/src/components/MobileHeader.tsx +233 -0
  85. package/src/components/MobileLayout.stories.tsx +338 -0
  86. package/src/components/MobileLayout.tsx +313 -0
  87. package/src/components/Modal.stories.tsx +388 -0
  88. package/src/components/Modal.tsx +122 -4
  89. package/src/components/PageHeader.stories.tsx +198 -0
  90. package/src/components/PageHeader.tsx +217 -0
  91. package/src/components/PullToRefresh.stories.tsx +321 -0
  92. package/src/components/PullToRefresh.tsx +294 -0
  93. package/src/components/QueryTransparency.tsx +1 -1
  94. package/src/components/SearchableList.stories.tsx +437 -0
  95. package/src/components/SearchableList.tsx +326 -0
  96. package/src/components/Select.stories.tsx +190 -0
  97. package/src/components/Select.tsx +353 -137
  98. package/src/components/Sidebar.tsx +193 -10
  99. package/src/components/SwipeActions.stories.tsx +327 -0
  100. package/src/components/SwipeActions.tsx +387 -0
  101. package/src/components/Switch.stories.tsx +158 -0
  102. package/src/components/Switch.tsx +12 -3
  103. package/src/components/Textarea.tsx +31 -1
  104. package/src/components/index.ts +69 -3
  105. package/src/context/MobileContext.tsx +296 -0
  106. package/src/hooks/useResponsive.ts +360 -0
  107. package/src/types/index.ts +4 -0
  108. 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[size];
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="text-sm font-medium text-ink-900">{label}</p>}
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 px-4 py-3 border rounded-lg text-sm text-ink-800 placeholder-ink-400
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}