@papernote/ui 1.3.1 → 1.5.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 (100) hide show
  1. package/dist/components/BottomNavigation.d.ts +98 -0
  2. package/dist/components/BottomNavigation.d.ts.map +1 -0
  3. package/dist/components/Checkbox.d.ts +2 -0
  4. package/dist/components/Checkbox.d.ts.map +1 -1
  5. package/dist/components/CheckboxList.d.ts +81 -0
  6. package/dist/components/CheckboxList.d.ts.map +1 -0
  7. package/dist/components/Chip.d.ts +92 -1
  8. package/dist/components/Chip.d.ts.map +1 -1
  9. package/dist/components/ConfirmDialog.d.ts +43 -1
  10. package/dist/components/ConfirmDialog.d.ts.map +1 -1
  11. package/dist/components/DataTable.d.ts +10 -1
  12. package/dist/components/DataTable.d.ts.map +1 -1
  13. package/dist/components/DataTableCardView.d.ts +99 -0
  14. package/dist/components/DataTableCardView.d.ts.map +1 -0
  15. package/dist/components/ExpandablePanel.d.ts +142 -0
  16. package/dist/components/ExpandablePanel.d.ts.map +1 -0
  17. package/dist/components/FloatingActionButton.d.ts +98 -0
  18. package/dist/components/FloatingActionButton.d.ts.map +1 -0
  19. package/dist/components/Input.d.ts +45 -1
  20. package/dist/components/Input.d.ts.map +1 -1
  21. package/dist/components/MobileHeader.d.ts +98 -0
  22. package/dist/components/MobileHeader.d.ts.map +1 -0
  23. package/dist/components/MobileLayout.d.ts +121 -0
  24. package/dist/components/MobileLayout.d.ts.map +1 -0
  25. package/dist/components/Modal.d.ts +50 -1
  26. package/dist/components/Modal.d.ts.map +1 -1
  27. package/dist/components/PullToRefresh.d.ts +87 -0
  28. package/dist/components/PullToRefresh.d.ts.map +1 -0
  29. package/dist/components/QueryTransparency.d.ts +1 -1
  30. package/dist/components/QueryTransparency.d.ts.map +1 -1
  31. package/dist/components/SearchableList.d.ts +83 -0
  32. package/dist/components/SearchableList.d.ts.map +1 -0
  33. package/dist/components/Select.d.ts +16 -2
  34. package/dist/components/Select.d.ts.map +1 -1
  35. package/dist/components/Sidebar.d.ts +40 -1
  36. package/dist/components/Sidebar.d.ts.map +1 -1
  37. package/dist/components/SwipeActions.d.ts +93 -0
  38. package/dist/components/SwipeActions.d.ts.map +1 -0
  39. package/dist/components/Switch.d.ts +1 -0
  40. package/dist/components/Switch.d.ts.map +1 -1
  41. package/dist/components/Textarea.d.ts +13 -0
  42. package/dist/components/Textarea.d.ts.map +1 -1
  43. package/dist/components/index.d.ts +27 -3
  44. package/dist/components/index.d.ts.map +1 -1
  45. package/dist/context/MobileContext.d.ts +168 -0
  46. package/dist/context/MobileContext.d.ts.map +1 -0
  47. package/dist/hooks/useResponsive.d.ts +158 -0
  48. package/dist/hooks/useResponsive.d.ts.map +1 -0
  49. package/dist/index.d.ts +1653 -56
  50. package/dist/index.esm.js +2832 -194
  51. package/dist/index.esm.js.map +1 -1
  52. package/dist/index.js +2865 -192
  53. package/dist/index.js.map +1 -1
  54. package/dist/styles.css +404 -1
  55. package/dist/types/index.d.ts +2 -0
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/package.json +1 -1
  58. package/src/components/BottomNavigation.stories.tsx +142 -0
  59. package/src/components/BottomNavigation.tsx +225 -0
  60. package/src/components/Checkbox.stories.tsx +162 -0
  61. package/src/components/Checkbox.tsx +22 -6
  62. package/src/components/CheckboxList.stories.tsx +311 -0
  63. package/src/components/CheckboxList.tsx +433 -0
  64. package/src/components/Chip.stories.tsx +389 -0
  65. package/src/components/Chip.tsx +182 -3
  66. package/src/components/ConfirmDialog.tsx +56 -4
  67. package/src/components/DataTable.tsx +60 -1
  68. package/src/components/DataTableCardView.stories.tsx +307 -0
  69. package/src/components/DataTableCardView.tsx +419 -0
  70. package/src/components/ExpandablePanel.stories.tsx +620 -0
  71. package/src/components/ExpandablePanel.tsx +383 -0
  72. package/src/components/FloatingActionButton.stories.tsx +197 -0
  73. package/src/components/FloatingActionButton.tsx +301 -0
  74. package/src/components/Grid.stories.tsx +16 -16
  75. package/src/components/Input.stories.tsx +214 -0
  76. package/src/components/Input.tsx +81 -4
  77. package/src/components/MobileHeader.stories.tsx +205 -0
  78. package/src/components/MobileHeader.tsx +233 -0
  79. package/src/components/MobileLayout.stories.tsx +338 -0
  80. package/src/components/MobileLayout.tsx +313 -0
  81. package/src/components/Modal.stories.tsx +183 -0
  82. package/src/components/Modal.tsx +84 -3
  83. package/src/components/PullToRefresh.stories.tsx +321 -0
  84. package/src/components/PullToRefresh.tsx +294 -0
  85. package/src/components/QueryTransparency.tsx +1 -1
  86. package/src/components/SearchableList.stories.tsx +437 -0
  87. package/src/components/SearchableList.tsx +326 -0
  88. package/src/components/Select.stories.tsx +190 -0
  89. package/src/components/Select.tsx +353 -137
  90. package/src/components/Sidebar.tsx +191 -8
  91. package/src/components/SwipeActions.stories.tsx +327 -0
  92. package/src/components/SwipeActions.tsx +387 -0
  93. package/src/components/Switch.stories.tsx +158 -0
  94. package/src/components/Switch.tsx +12 -3
  95. package/src/components/Textarea.tsx +31 -1
  96. package/src/components/index.ts +63 -3
  97. package/src/context/MobileContext.tsx +296 -0
  98. package/src/hooks/useResponsive.ts +360 -0
  99. package/src/types/index.ts +4 -0
  100. package/tailwind.config.js +56 -1
@@ -348,3 +348,186 @@ export const TextSelectionTest: Story = {
348
348
  );
349
349
  },
350
350
  };
351
+
352
+ // Mobile-optimized stories
353
+ export const MobileSlideUp: Story = {
354
+ parameters: {
355
+ viewport: { defaultViewport: 'mobile1' },
356
+ docs: {
357
+ description: {
358
+ story: 'On mobile devices, slide-up animation feels natural like a native bottom sheet, ideal for mobile modals.',
359
+ },
360
+ },
361
+ },
362
+ render: () => {
363
+ const [isOpen, setIsOpen] = useState(false);
364
+ return (
365
+ <>
366
+ <Button onClick={() => setIsOpen(true)}>Open Mobile Modal</Button>
367
+ <Modal
368
+ isOpen={isOpen}
369
+ onClose={() => setIsOpen(false)}
370
+ title="Mobile Modal"
371
+ animation="slide-up"
372
+ size="lg"
373
+ >
374
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
375
+ <p>This modal slides up from the bottom, which feels natural on mobile devices.</p>
376
+ <Input
377
+ label="Email"
378
+ type="email"
379
+ inputMode="email"
380
+ enterKeyHint="next"
381
+ size="lg"
382
+ placeholder="you@example.com"
383
+ />
384
+ </div>
385
+ <ModalFooter>
386
+ <Button variant="ghost" onClick={() => setIsOpen(false)}>Cancel</Button>
387
+ <Button variant="primary" onClick={() => setIsOpen(false)}>Save</Button>
388
+ </ModalFooter>
389
+ </Modal>
390
+ </>
391
+ );
392
+ },
393
+ };
394
+
395
+ export const MobileFormModal: Story = {
396
+ parameters: {
397
+ viewport: { defaultViewport: 'mobile1' },
398
+ docs: {
399
+ description: {
400
+ story: 'Mobile form modal with touch-friendly inputs (size="lg") and appropriate keyboard hints.',
401
+ },
402
+ },
403
+ },
404
+ render: () => {
405
+ const [isOpen, setIsOpen] = useState(false);
406
+ return (
407
+ <>
408
+ <Button onClick={() => setIsOpen(true)}>Add Contact</Button>
409
+ <Modal
410
+ isOpen={isOpen}
411
+ onClose={() => setIsOpen(false)}
412
+ title="Add New Contact"
413
+ animation="slide-up"
414
+ size="lg"
415
+ >
416
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
417
+ <Input
418
+ label="Full Name"
419
+ size="lg"
420
+ enterKeyHint="next"
421
+ placeholder="John Smith"
422
+ required
423
+ />
424
+ <Input
425
+ label="Phone Number"
426
+ type="tel"
427
+ inputMode="tel"
428
+ enterKeyHint="next"
429
+ size="lg"
430
+ placeholder="(555) 123-4567"
431
+ />
432
+ <Input
433
+ label="Email"
434
+ type="email"
435
+ inputMode="email"
436
+ enterKeyHint="done"
437
+ size="lg"
438
+ placeholder="john@example.com"
439
+ />
440
+ </div>
441
+ <ModalFooter>
442
+ <Button variant="ghost" size="lg" onClick={() => setIsOpen(false)}>Cancel</Button>
443
+ <Button variant="primary" size="lg" onClick={() => setIsOpen(false)}>Save Contact</Button>
444
+ </ModalFooter>
445
+ </Modal>
446
+ </>
447
+ );
448
+ },
449
+ };
450
+
451
+ export const MobileConfirmation: Story = {
452
+ parameters: {
453
+ viewport: { defaultViewport: 'mobile1' },
454
+ docs: {
455
+ description: {
456
+ story: 'Small confirmation modal on mobile with scale animation and touch-friendly buttons.',
457
+ },
458
+ },
459
+ },
460
+ render: () => {
461
+ const [isOpen, setIsOpen] = useState(false);
462
+ return (
463
+ <>
464
+ <Button variant="danger" onClick={() => setIsOpen(true)}>Delete Item</Button>
465
+ <Modal
466
+ isOpen={isOpen}
467
+ onClose={() => setIsOpen(false)}
468
+ title="Delete Item?"
469
+ animation="scale"
470
+ size="sm"
471
+ >
472
+ <p style={{ marginBottom: '1rem' }}>
473
+ Are you sure you want to delete this item? This action cannot be undone.
474
+ </p>
475
+ <ModalFooter>
476
+ <Button variant="ghost" size="lg" onClick={() => setIsOpen(false)}>Cancel</Button>
477
+ <Button variant="danger" size="lg" onClick={() => setIsOpen(false)}>Delete</Button>
478
+ </ModalFooter>
479
+ </Modal>
480
+ </>
481
+ );
482
+ },
483
+ };
484
+
485
+ export const MobileFullScreen: Story = {
486
+ parameters: {
487
+ viewport: { defaultViewport: 'mobile1' },
488
+ docs: {
489
+ description: {
490
+ story: 'Full-screen modal on mobile for complex content that needs maximum space.',
491
+ },
492
+ },
493
+ },
494
+ render: () => {
495
+ const [isOpen, setIsOpen] = useState(false);
496
+ return (
497
+ <>
498
+ <Button onClick={() => setIsOpen(true)}>View Details</Button>
499
+ <Modal
500
+ isOpen={isOpen}
501
+ onClose={() => setIsOpen(false)}
502
+ title="Product Details"
503
+ animation="slide-up"
504
+ size="full"
505
+ >
506
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
507
+ <div style={{ backgroundColor: '#f5f5f4', padding: '3rem', borderRadius: '0.5rem', textAlign: 'center' }}>
508
+ [Product Image]
509
+ </div>
510
+ <h3 style={{ fontSize: '1.25rem', fontWeight: 600 }}>Product Name</h3>
511
+ <p style={{ color: '#666' }}>$99.99</p>
512
+ <p>
513
+ This is a detailed product description that takes advantage of the full-screen
514
+ modal on mobile devices to show comprehensive information.
515
+ </p>
516
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
517
+ <h4 style={{ fontWeight: 600 }}>Features:</h4>
518
+ <ul style={{ marginLeft: '1.5rem' }}>
519
+ <li>Feature 1</li>
520
+ <li>Feature 2</li>
521
+ <li>Feature 3</li>
522
+ </ul>
523
+ </div>
524
+ </div>
525
+ <ModalFooter>
526
+ <Button variant="ghost" size="lg" onClick={() => setIsOpen(false)}>Close</Button>
527
+ <Button variant="primary" size="lg" onClick={() => setIsOpen(false)}>Add to Cart</Button>
528
+ </ModalFooter>
529
+ </Modal>
530
+ </>
531
+ );
532
+ },
533
+ };
@@ -1,5 +1,7 @@
1
1
  import React, { useEffect, useRef, useId } from 'react';
2
2
  import { X } from 'lucide-react';
3
+ import { useIsMobile } from '../hooks/useResponsive';
4
+ import BottomSheet from './BottomSheet';
3
5
 
4
6
  export interface ModalProps {
5
7
  isOpen: boolean;
@@ -10,6 +12,14 @@ export interface ModalProps {
10
12
  showCloseButton?: boolean;
11
13
  /** Animation variant for modal entrance (default: 'scale') */
12
14
  animation?: 'scale' | 'slide-up' | 'slide-down' | 'fade' | 'none';
15
+
16
+ // Mobile behavior props
17
+ /** Mobile display mode: 'auto' uses BottomSheet on mobile, 'modal' always uses modal, 'sheet' always uses BottomSheet */
18
+ mobileMode?: 'auto' | 'modal' | 'sheet';
19
+ /** Height preset for BottomSheet on mobile (default: 'lg') */
20
+ mobileHeight?: 'sm' | 'md' | 'lg' | 'full';
21
+ /** Show drag handle on BottomSheet (default: true) */
22
+ mobileShowHandle?: boolean;
13
23
  }
14
24
 
15
25
  const sizeClasses = {
@@ -20,6 +30,49 @@ const sizeClasses = {
20
30
  full: 'max-w-7xl',
21
31
  };
22
32
 
33
+ /**
34
+ * Modal - Adaptive dialog component
35
+ *
36
+ * On desktop, renders as a centered modal dialog.
37
+ * On mobile (when mobileMode='auto'), automatically renders as a BottomSheet
38
+ * for better touch interaction and visibility.
39
+ *
40
+ * @example Basic modal
41
+ * ```tsx
42
+ * <Modal isOpen={isOpen} onClose={handleClose} title="Edit User">
43
+ * <form>...</form>
44
+ * <ModalFooter>
45
+ * <Button onClick={handleClose}>Cancel</Button>
46
+ * <Button variant="primary" onClick={handleSave}>Save</Button>
47
+ * </ModalFooter>
48
+ * </Modal>
49
+ * ```
50
+ *
51
+ * @example Force modal on mobile
52
+ * ```tsx
53
+ * <Modal
54
+ * isOpen={isOpen}
55
+ * onClose={handleClose}
56
+ * title="Settings"
57
+ * mobileMode="modal"
58
+ * >
59
+ * ...
60
+ * </Modal>
61
+ * ```
62
+ *
63
+ * @example Always use BottomSheet
64
+ * ```tsx
65
+ * <Modal
66
+ * isOpen={isOpen}
67
+ * onClose={handleClose}
68
+ * title="Select Option"
69
+ * mobileMode="sheet"
70
+ * mobileHeight="md"
71
+ * >
72
+ * ...
73
+ * </Modal>
74
+ * ```
75
+ */
23
76
  export default function Modal({
24
77
  isOpen,
25
78
  onClose,
@@ -28,13 +81,24 @@ export default function Modal({
28
81
  size = 'md',
29
82
  showCloseButton = true,
30
83
  animation = 'scale',
84
+ mobileMode = 'auto',
85
+ mobileHeight = 'lg',
86
+ mobileShowHandle = true,
31
87
  }: ModalProps) {
32
88
  const modalRef = useRef<HTMLDivElement>(null);
33
89
  const mouseDownOnBackdrop = useRef(false);
34
90
  const titleId = useId();
91
+ const isMobile = useIsMobile();
35
92
 
36
- // Handle escape key
93
+ // Determine if we should use BottomSheet
94
+ const useBottomSheet =
95
+ mobileMode === 'sheet' ||
96
+ (mobileMode === 'auto' && isMobile);
97
+
98
+ // Handle escape key (only for modal mode, BottomSheet handles its own)
37
99
  useEffect(() => {
100
+ if (useBottomSheet) return; // BottomSheet handles escape
101
+
38
102
  const handleEscape = (e: KeyboardEvent) => {
39
103
  if (e.key === 'Escape' && isOpen) {
40
104
  onClose();
@@ -50,7 +114,7 @@ export default function Modal({
50
114
  document.removeEventListener('keydown', handleEscape);
51
115
  document.body.style.overflow = 'unset';
52
116
  };
53
- }, [isOpen, onClose]);
117
+ }, [isOpen, onClose, useBottomSheet]);
54
118
 
55
119
  // Track if mousedown originated on the backdrop
56
120
  const handleBackdropMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -89,6 +153,23 @@ export default function Modal({
89
153
 
90
154
  if (!isOpen) return null;
91
155
 
156
+ // Render as BottomSheet on mobile
157
+ if (useBottomSheet) {
158
+ return (
159
+ <BottomSheet
160
+ isOpen={isOpen}
161
+ onClose={onClose}
162
+ title={title}
163
+ height={mobileHeight}
164
+ showHandle={mobileShowHandle}
165
+ showCloseButton={showCloseButton}
166
+ >
167
+ {children}
168
+ </BottomSheet>
169
+ );
170
+ }
171
+
172
+ // Render as standard modal on desktop
92
173
  return (
93
174
  <div
94
175
  className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-900 bg-opacity-50 backdrop-blur-sm animate-fade-in"
@@ -127,7 +208,7 @@ export default function Modal({
127
208
 
128
209
  export function ModalFooter({ children }: { children: React.ReactNode }) {
129
210
  return (
130
- <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-paper-200 bg-paper-50">
211
+ <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-paper-200 bg-paper-50 -mx-6 -mb-4 mt-4 rounded-b-xl">
131
212
  {children}
132
213
  </div>
133
214
  );
@@ -0,0 +1,321 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import { RefreshCw } from 'lucide-react';
4
+ import PullToRefresh from './PullToRefresh';
5
+
6
+ const meta: Meta<typeof PullToRefresh> = {
7
+ title: 'Mobile/PullToRefresh',
8
+ component: PullToRefresh,
9
+ parameters: {
10
+ layout: 'fullscreen',
11
+ viewport: {
12
+ defaultViewport: 'mobile1',
13
+ },
14
+ docs: {
15
+ description: {
16
+ component: 'Native-feeling pull-to-refresh gesture handler for mobile content. Only activates when scrolled to top.',
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof PullToRefresh>;
24
+
25
+ // Helper component for interactive stories
26
+ const RefreshableList = ({
27
+ itemCount = 10,
28
+ pullThreshold = 80,
29
+ maxPull = 120,
30
+ disabled = false,
31
+ }: {
32
+ itemCount?: number;
33
+ pullThreshold?: number;
34
+ maxPull?: number;
35
+ disabled?: boolean;
36
+ }) => {
37
+ const [items, setItems] = useState(() =>
38
+ Array.from({ length: itemCount }, (_, i) => ({
39
+ id: i + 1,
40
+ title: `Item ${i + 1}`,
41
+ timestamp: new Date().toLocaleTimeString(),
42
+ }))
43
+ );
44
+ const [refreshCount, setRefreshCount] = useState(0);
45
+
46
+ const handleRefresh = async () => {
47
+ // Simulate API call
48
+ await new Promise(resolve => setTimeout(resolve, 1500));
49
+
50
+ setRefreshCount(prev => prev + 1);
51
+ setItems(prev => [
52
+ {
53
+ id: Date.now(),
54
+ title: `New Item (Refresh #${refreshCount + 1})`,
55
+ timestamp: new Date().toLocaleTimeString(),
56
+ },
57
+ ...prev,
58
+ ]);
59
+ };
60
+
61
+ return (
62
+ <PullToRefresh
63
+ onRefresh={handleRefresh}
64
+ pullThreshold={pullThreshold}
65
+ maxPull={maxPull}
66
+ disabled={disabled}
67
+ className="h-screen"
68
+ >
69
+ <div style={{ padding: '16px', background: '#f5f5f4', minHeight: '100vh' }}>
70
+ <div style={{
71
+ padding: '12px 16px',
72
+ background: '#fef3c7',
73
+ borderRadius: '8px',
74
+ marginBottom: '16px',
75
+ fontSize: '14px',
76
+ }}>
77
+ <strong>Pull down to refresh</strong>
78
+ <p style={{ color: '#666', marginTop: '4px' }}>
79
+ Refreshed {refreshCount} times. Items: {items.length}
80
+ </p>
81
+ </div>
82
+
83
+ {items.map((item) => (
84
+ <div
85
+ key={item.id}
86
+ style={{
87
+ padding: '16px',
88
+ margin: '8px 0',
89
+ background: 'white',
90
+ borderRadius: '8px',
91
+ boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
92
+ }}
93
+ >
94
+ <div style={{ fontWeight: '500' }}>{item.title}</div>
95
+ <div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
96
+ Added at {item.timestamp}
97
+ </div>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </PullToRefresh>
102
+ );
103
+ };
104
+
105
+ export const Default: Story = {
106
+ render: () => <RefreshableList />,
107
+ parameters: {
108
+ docs: {
109
+ description: {
110
+ story: 'Pull down from the top to trigger a refresh. The spinner appears after pulling past the threshold.',
111
+ },
112
+ },
113
+ },
114
+ };
115
+
116
+ export const LowThreshold: Story = {
117
+ render: () => <RefreshableList pullThreshold={50} maxPull={80} />,
118
+ parameters: {
119
+ docs: {
120
+ description: {
121
+ story: 'Lower threshold (50px) makes it easier to trigger refresh.',
122
+ },
123
+ },
124
+ },
125
+ };
126
+
127
+ export const HighThreshold: Story = {
128
+ render: () => <RefreshableList pullThreshold={120} maxPull={180} />,
129
+ parameters: {
130
+ docs: {
131
+ description: {
132
+ story: 'Higher threshold (120px) requires more pull distance to trigger refresh.',
133
+ },
134
+ },
135
+ },
136
+ };
137
+
138
+ export const Disabled: Story = {
139
+ render: () => <RefreshableList disabled />,
140
+ parameters: {
141
+ docs: {
142
+ description: {
143
+ story: 'Pull-to-refresh can be disabled when not needed.',
144
+ },
145
+ },
146
+ },
147
+ };
148
+
149
+ export const CustomIndicator: Story = {
150
+ render: () => {
151
+ const [items, setItems] = useState(
152
+ Array.from({ length: 5 }, (_, i) => `Item ${i + 1}`)
153
+ );
154
+
155
+ const handleRefresh = async () => {
156
+ await new Promise(resolve => setTimeout(resolve, 1000));
157
+ setItems(prev => [`New Item ${Date.now()}`, ...prev]);
158
+ };
159
+
160
+ return (
161
+ <PullToRefresh
162
+ onRefresh={handleRefresh}
163
+ loadingIndicator={
164
+ <RefreshCw className="h-6 w-6 text-green-600 animate-spin" />
165
+ }
166
+ pullIndicator={
167
+ <RefreshCw className="h-6 w-6 text-gray-400" />
168
+ }
169
+ className="h-screen"
170
+ >
171
+ <div style={{ padding: '16px', background: '#f5f5f4', minHeight: '100vh' }}>
172
+ <div style={{
173
+ padding: '12px 16px',
174
+ background: '#dcfce7',
175
+ borderRadius: '8px',
176
+ marginBottom: '16px',
177
+ }}>
178
+ Custom refresh indicator (green spinner)
179
+ </div>
180
+ {items.map((item, i) => (
181
+ <div
182
+ key={i}
183
+ style={{
184
+ padding: '16px',
185
+ margin: '8px 0',
186
+ background: 'white',
187
+ borderRadius: '8px'
188
+ }}
189
+ >
190
+ {item}
191
+ </div>
192
+ ))}
193
+ </div>
194
+ </PullToRefresh>
195
+ );
196
+ },
197
+ };
198
+
199
+ export const WithLongContent: Story = {
200
+ render: () => <RefreshableList itemCount={30} />,
201
+ parameters: {
202
+ docs: {
203
+ description: {
204
+ story: 'With long scrollable content. Pull-to-refresh only activates when scrolled to the very top.',
205
+ },
206
+ },
207
+ },
208
+ };
209
+
210
+ export const EmptyState: Story = {
211
+ render: () => {
212
+ const [items, setItems] = useState<string[]>([]);
213
+ const [loading, setLoading] = useState(false);
214
+
215
+ const handleRefresh = async () => {
216
+ setLoading(true);
217
+ await new Promise(resolve => setTimeout(resolve, 1500));
218
+ setItems(['Fetched Item 1', 'Fetched Item 2', 'Fetched Item 3']);
219
+ setLoading(false);
220
+ };
221
+
222
+ return (
223
+ <PullToRefresh onRefresh={handleRefresh} className="h-screen">
224
+ <div style={{
225
+ padding: '16px',
226
+ background: '#f5f5f4',
227
+ minHeight: '100vh',
228
+ display: 'flex',
229
+ flexDirection: 'column',
230
+ }}>
231
+ {items.length === 0 ? (
232
+ <div style={{
233
+ flex: 1,
234
+ display: 'flex',
235
+ flexDirection: 'column',
236
+ alignItems: 'center',
237
+ justifyContent: 'center',
238
+ color: '#666',
239
+ }}>
240
+ <RefreshCw className="h-12 w-12 mb-4 text-gray-300" />
241
+ <p style={{ fontWeight: '500' }}>No items yet</p>
242
+ <p style={{ fontSize: '14px' }}>Pull down to load items</p>
243
+ </div>
244
+ ) : (
245
+ items.map((item, i) => (
246
+ <div
247
+ key={i}
248
+ style={{
249
+ padding: '16px',
250
+ margin: '8px 0',
251
+ background: 'white',
252
+ borderRadius: '8px'
253
+ }}
254
+ >
255
+ {item}
256
+ </div>
257
+ ))
258
+ )}
259
+ </div>
260
+ </PullToRefresh>
261
+ );
262
+ },
263
+ parameters: {
264
+ docs: {
265
+ description: {
266
+ story: 'Pull-to-refresh works well with empty states to load initial data.',
267
+ },
268
+ },
269
+ },
270
+ };
271
+
272
+ export const MobileInstructions: Story = {
273
+ render: () => (
274
+ <div style={{ padding: '24px', background: '#f5f5f4', minHeight: '100vh' }}>
275
+ <h2 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
276
+ Pull to Refresh Component
277
+ </h2>
278
+
279
+ <div style={{ background: 'white', padding: '16px', borderRadius: '8px', marginBottom: '16px' }}>
280
+ <h3 style={{ fontWeight: '600', marginBottom: '8px' }}>Usage</h3>
281
+ <pre style={{
282
+ background: '#f1f5f9',
283
+ padding: '12px',
284
+ borderRadius: '4px',
285
+ fontSize: '12px',
286
+ overflow: 'auto',
287
+ }}>
288
+ {`<PullToRefresh
289
+ onRefresh={async () => {
290
+ await fetchLatestData();
291
+ }}
292
+ pullThreshold={80}
293
+ maxPull={120}
294
+ >
295
+ {content}
296
+ </PullToRefresh>`}
297
+ </pre>
298
+ </div>
299
+
300
+ <div style={{ background: 'white', padding: '16px', borderRadius: '8px', marginBottom: '16px' }}>
301
+ <h3 style={{ fontWeight: '600', marginBottom: '8px' }}>Props</h3>
302
+ <ul style={{ fontSize: '14px', lineHeight: '1.6' }}>
303
+ <li><strong>onRefresh</strong>: Async function called on refresh</li>
304
+ <li><strong>pullThreshold</strong>: Distance to trigger (default: 80px)</li>
305
+ <li><strong>maxPull</strong>: Maximum pull distance (default: 120px)</li>
306
+ <li><strong>disabled</strong>: Disable pull-to-refresh</li>
307
+ <li><strong>loadingIndicator</strong>: Custom loading spinner</li>
308
+ <li><strong>pullIndicator</strong>: Custom pull indicator</li>
309
+ </ul>
310
+ </div>
311
+
312
+ <div style={{ background: '#fef3c7', padding: '16px', borderRadius: '8px' }}>
313
+ <h3 style={{ fontWeight: '600', marginBottom: '8px' }}>Testing</h3>
314
+ <p style={{ fontSize: '14px' }}>
315
+ To test on desktop, use Chrome DevTools mobile emulation with touch simulation enabled.
316
+ Click and drag down from the top of the content area.
317
+ </p>
318
+ </div>
319
+ </div>
320
+ ),
321
+ };