@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.
- 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 +50 -1
- package/dist/components/Modal.d.ts.map +1 -1
- 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 +27 -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 +1653 -56
- package/dist/index.esm.js +2832 -194
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2865 -192
- package/dist/index.js.map +1 -1
- package/dist/styles.css +404 -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/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 +183 -0
- package/src/components/Modal.tsx +84 -3
- 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 +191 -8
- 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 +63 -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
|
@@ -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
|
+
};
|
package/src/components/Modal.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
+
};
|