@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,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
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { Loader2, ArrowDown } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PullToRefresh component props
|
|
6
|
+
*/
|
|
7
|
+
export interface PullToRefreshProps {
|
|
8
|
+
/** Content to wrap */
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Async refresh handler - should return a Promise */
|
|
11
|
+
onRefresh: () => Promise<void>;
|
|
12
|
+
/** Disable pull-to-refresh */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** Pull distance required to trigger refresh (default: 80) */
|
|
15
|
+
pullThreshold?: number;
|
|
16
|
+
/** Maximum pull distance (default: 120) */
|
|
17
|
+
maxPull?: number;
|
|
18
|
+
/** Custom loading indicator */
|
|
19
|
+
loadingIndicator?: React.ReactNode;
|
|
20
|
+
/** Custom pull indicator */
|
|
21
|
+
pullIndicator?: React.ReactNode;
|
|
22
|
+
/** Additional class names for container */
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type RefreshState = 'idle' | 'pulling' | 'ready' | 'refreshing';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* PullToRefresh - Mobile pull-to-refresh gesture handler
|
|
30
|
+
*
|
|
31
|
+
* Wraps content and provides native-feeling pull-to-refresh functionality.
|
|
32
|
+
* Only activates when scrolled to top of content.
|
|
33
|
+
*
|
|
34
|
+
* @example Basic usage
|
|
35
|
+
* ```tsx
|
|
36
|
+
* <PullToRefresh onRefresh={async () => {
|
|
37
|
+
* await fetchLatestData();
|
|
38
|
+
* }}>
|
|
39
|
+
* <div className="min-h-screen">
|
|
40
|
+
* {content}
|
|
41
|
+
* </div>
|
|
42
|
+
* </PullToRefresh>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @example With custom threshold
|
|
46
|
+
* ```tsx
|
|
47
|
+
* <PullToRefresh
|
|
48
|
+
* onRefresh={handleRefresh}
|
|
49
|
+
* pullThreshold={100}
|
|
50
|
+
* maxPull={150}
|
|
51
|
+
* >
|
|
52
|
+
* {content}
|
|
53
|
+
* </PullToRefresh>
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export default function PullToRefresh({
|
|
57
|
+
children,
|
|
58
|
+
onRefresh,
|
|
59
|
+
disabled = false,
|
|
60
|
+
pullThreshold = 80,
|
|
61
|
+
maxPull = 120,
|
|
62
|
+
loadingIndicator,
|
|
63
|
+
pullIndicator,
|
|
64
|
+
className = '',
|
|
65
|
+
}: PullToRefreshProps) {
|
|
66
|
+
const [state, setState] = useState<RefreshState>('idle');
|
|
67
|
+
const [pullDistance, setPullDistance] = useState(0);
|
|
68
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
69
|
+
const startY = useRef(0);
|
|
70
|
+
const currentY = useRef(0);
|
|
71
|
+
|
|
72
|
+
// Check if at top of scroll container
|
|
73
|
+
const isAtTop = useCallback(() => {
|
|
74
|
+
const container = containerRef.current;
|
|
75
|
+
if (!container) return false;
|
|
76
|
+
return container.scrollTop <= 0;
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
// Handle touch start
|
|
80
|
+
const handleTouchStart = useCallback((e: TouchEvent) => {
|
|
81
|
+
if (disabled || state === 'refreshing' || !isAtTop()) return;
|
|
82
|
+
|
|
83
|
+
startY.current = e.touches[0].clientY;
|
|
84
|
+
currentY.current = startY.current;
|
|
85
|
+
}, [disabled, state, isAtTop]);
|
|
86
|
+
|
|
87
|
+
// Handle touch move
|
|
88
|
+
const handleTouchMove = useCallback((e: TouchEvent) => {
|
|
89
|
+
if (disabled || state === 'refreshing') return;
|
|
90
|
+
if (startY.current === 0) return;
|
|
91
|
+
|
|
92
|
+
currentY.current = e.touches[0].clientY;
|
|
93
|
+
const diff = currentY.current - startY.current;
|
|
94
|
+
|
|
95
|
+
// Only allow pulling down when at top
|
|
96
|
+
if (diff > 0 && isAtTop()) {
|
|
97
|
+
// Apply resistance - pull slows down as distance increases
|
|
98
|
+
const resistance = 0.5;
|
|
99
|
+
const adjustedPull = Math.min(diff * resistance, maxPull);
|
|
100
|
+
|
|
101
|
+
setPullDistance(adjustedPull);
|
|
102
|
+
setState(adjustedPull >= pullThreshold ? 'ready' : 'pulling');
|
|
103
|
+
|
|
104
|
+
// Prevent default scroll when pulling
|
|
105
|
+
if (adjustedPull > 0) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, [disabled, state, isAtTop, maxPull, pullThreshold]);
|
|
110
|
+
|
|
111
|
+
// Handle touch end
|
|
112
|
+
const handleTouchEnd = useCallback(async () => {
|
|
113
|
+
if (disabled || state === 'refreshing') return;
|
|
114
|
+
|
|
115
|
+
if (state === 'ready') {
|
|
116
|
+
setState('refreshing');
|
|
117
|
+
setPullDistance(pullThreshold); // Hold at threshold while refreshing
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await onRefresh();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('Refresh failed:', error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setState('idle');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setPullDistance(0);
|
|
129
|
+
startY.current = 0;
|
|
130
|
+
currentY.current = 0;
|
|
131
|
+
}, [disabled, state, pullThreshold, onRefresh]);
|
|
132
|
+
|
|
133
|
+
// Attach touch listeners
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
const container = containerRef.current;
|
|
136
|
+
if (!container) return;
|
|
137
|
+
|
|
138
|
+
container.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
139
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
140
|
+
container.addEventListener('touchend', handleTouchEnd);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
container.removeEventListener('touchstart', handleTouchStart);
|
|
144
|
+
container.removeEventListener('touchmove', handleTouchMove);
|
|
145
|
+
container.removeEventListener('touchend', handleTouchEnd);
|
|
146
|
+
};
|
|
147
|
+
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);
|
|
148
|
+
|
|
149
|
+
// Calculate indicator opacity and rotation
|
|
150
|
+
const progress = Math.min(pullDistance / pullThreshold, 1);
|
|
151
|
+
const rotation = progress * 180;
|
|
152
|
+
|
|
153
|
+
// Default loading indicator
|
|
154
|
+
const defaultLoadingIndicator = (
|
|
155
|
+
<Loader2 className="h-6 w-6 text-accent-600 animate-spin" />
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Default pull indicator
|
|
159
|
+
const defaultPullIndicator = (
|
|
160
|
+
<div
|
|
161
|
+
className={`
|
|
162
|
+
transition-transform duration-200
|
|
163
|
+
${state === 'ready' ? 'text-accent-600' : 'text-ink-400'}
|
|
164
|
+
`}
|
|
165
|
+
style={{ transform: `rotate(${rotation}deg)` }}
|
|
166
|
+
>
|
|
167
|
+
<ArrowDown className="h-6 w-6" />
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
ref={containerRef}
|
|
174
|
+
className={`relative overflow-auto ${className}`}
|
|
175
|
+
style={{ touchAction: pullDistance > 0 ? 'none' : 'auto' }}
|
|
176
|
+
>
|
|
177
|
+
{/* Pull indicator */}
|
|
178
|
+
<div
|
|
179
|
+
className={`
|
|
180
|
+
absolute left-0 right-0 flex items-center justify-center
|
|
181
|
+
transition-all duration-200 overflow-hidden
|
|
182
|
+
${state === 'idle' && pullDistance === 0 ? 'opacity-0' : 'opacity-100'}
|
|
183
|
+
`}
|
|
184
|
+
style={{
|
|
185
|
+
height: `${pullDistance}px`,
|
|
186
|
+
top: 0,
|
|
187
|
+
zIndex: 10,
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<div
|
|
191
|
+
className={`
|
|
192
|
+
w-10 h-10 rounded-full bg-white shadow-md
|
|
193
|
+
flex items-center justify-center
|
|
194
|
+
transition-transform duration-200
|
|
195
|
+
${state === 'refreshing' ? 'scale-100' : progress < 0.3 ? 'scale-75' : 'scale-100'}
|
|
196
|
+
`}
|
|
197
|
+
>
|
|
198
|
+
{state === 'refreshing'
|
|
199
|
+
? (loadingIndicator || defaultLoadingIndicator)
|
|
200
|
+
: (pullIndicator || defaultPullIndicator)
|
|
201
|
+
}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Content wrapper */}
|
|
206
|
+
<div
|
|
207
|
+
className="transition-transform duration-200"
|
|
208
|
+
style={{
|
|
209
|
+
transform: `translateY(${pullDistance}px)`,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{children}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* usePullToRefresh - Hook for custom pull-to-refresh implementations
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```tsx
|
|
223
|
+
* const { pullDistance, isRefreshing, bind } = usePullToRefresh({
|
|
224
|
+
* onRefresh: async () => {
|
|
225
|
+
* await fetchData();
|
|
226
|
+
* }
|
|
227
|
+
* });
|
|
228
|
+
*
|
|
229
|
+
* return (
|
|
230
|
+
* <div {...bind}>
|
|
231
|
+
* {isRefreshing && <Spinner />}
|
|
232
|
+
* {content}
|
|
233
|
+
* </div>
|
|
234
|
+
* );
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
export function usePullToRefresh({
|
|
238
|
+
onRefresh,
|
|
239
|
+
pullThreshold = 80,
|
|
240
|
+
maxPull = 120,
|
|
241
|
+
disabled = false,
|
|
242
|
+
}: {
|
|
243
|
+
onRefresh: () => Promise<void>;
|
|
244
|
+
pullThreshold?: number;
|
|
245
|
+
maxPull?: number;
|
|
246
|
+
disabled?: boolean;
|
|
247
|
+
}) {
|
|
248
|
+
const [pullDistance, setPullDistance] = useState(0);
|
|
249
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
250
|
+
const startY = useRef(0);
|
|
251
|
+
|
|
252
|
+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
253
|
+
if (disabled || isRefreshing) return;
|
|
254
|
+
startY.current = e.touches[0].clientY;
|
|
255
|
+
}, [disabled, isRefreshing]);
|
|
256
|
+
|
|
257
|
+
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
258
|
+
if (disabled || isRefreshing || startY.current === 0) return;
|
|
259
|
+
|
|
260
|
+
const diff = e.touches[0].clientY - startY.current;
|
|
261
|
+
if (diff > 0) {
|
|
262
|
+
const adjustedPull = Math.min(diff * 0.5, maxPull);
|
|
263
|
+
setPullDistance(adjustedPull);
|
|
264
|
+
}
|
|
265
|
+
}, [disabled, isRefreshing, maxPull]);
|
|
266
|
+
|
|
267
|
+
const handleTouchEnd = useCallback(async () => {
|
|
268
|
+
if (disabled || isRefreshing) return;
|
|
269
|
+
|
|
270
|
+
if (pullDistance >= pullThreshold) {
|
|
271
|
+
setIsRefreshing(true);
|
|
272
|
+
try {
|
|
273
|
+
await onRefresh();
|
|
274
|
+
} finally {
|
|
275
|
+
setIsRefreshing(false);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setPullDistance(0);
|
|
280
|
+
startY.current = 0;
|
|
281
|
+
}, [disabled, isRefreshing, pullDistance, pullThreshold, onRefresh]);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
pullDistance,
|
|
285
|
+
isRefreshing,
|
|
286
|
+
isReady: pullDistance >= pullThreshold,
|
|
287
|
+
progress: Math.min(pullDistance / pullThreshold, 1),
|
|
288
|
+
bind: {
|
|
289
|
+
onTouchStart: handleTouchStart,
|
|
290
|
+
onTouchMove: handleTouchMove,
|
|
291
|
+
onTouchEnd: handleTouchEnd,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -37,7 +37,7 @@ export interface QueryTransparencyInfo {
|
|
|
37
37
|
relatedData?: Array<{
|
|
38
38
|
entity: string;
|
|
39
39
|
description: string;
|
|
40
|
-
type: 'join' | 'include' | 'lookup';
|
|
40
|
+
type: 'primary' | 'join' | 'include' | 'lookup';
|
|
41
41
|
}>;
|
|
42
42
|
// Backend calculations
|
|
43
43
|
calculations?: Array<{
|