@rokku-x/react-hook-loading-spinner 0.1.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/README.md +639 -0
- package/dist/components/Loading.d.ts +3 -0
- package/dist/components/LoadingRenderer.d.ts +21 -0
- package/dist/hooks/useLoading.d.ts +19 -0
- package/dist/index.cjs.js +30 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.esm.js +387 -0
- package/dist/utils/EventEmitter.d.ts +9 -0
- package/package.json +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
# react-hook-loading-spinner
|
|
2
|
+
|
|
3
|
+
A lightweight and flexible React loading state hook library with built-in spinner components, global state management, and zero dependencies (except React and Zustand).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @rokku-x/react-hook-loading-spinner
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- 🎯 **Global Loading State** - Centralized loading management across your entire app
|
|
14
|
+
- 🪝 **React Hooks API** - Easy-to-use hook-based interface with automatic cleanup
|
|
15
|
+
- 🔄 **Reference Counting** - Multiple components can start/stop loading independently
|
|
16
|
+
- ⚡ **Async/Await Support** - Built-in async wrapper for promises
|
|
17
|
+
- 🎨 **Customizable Spinners** - Pre-built components or bring your own
|
|
18
|
+
- 📦 **TypeScript Support** - Full type safety out of the box
|
|
19
|
+
- 🎭 **Multiple Animations** - Spin, fade, or no animation
|
|
20
|
+
- 📡 **Event System** - Subscribe to loading state changes
|
|
21
|
+
- ♿ **Accessibility** - Built-in inert attribute and scroll prevention
|
|
22
|
+
- 📱 **Zero Dependencies** - Only requires React and Zustand
|
|
23
|
+
|
|
24
|
+
## Bundle Size
|
|
25
|
+
|
|
26
|
+
- ESM: 4.23 kB gzipped (12.80 kB raw)
|
|
27
|
+
- CJS: 3.67 kB gzipped (9.31 kB raw)
|
|
28
|
+
|
|
29
|
+
## Runtime Performance
|
|
30
|
+
|
|
31
|
+
- **startLoading()**: 0.025ms per operation
|
|
32
|
+
- **stopLoading()**: 0.0006ms per operation
|
|
33
|
+
- **start/stop cycle**: 0.023ms per cycle
|
|
34
|
+
- **asyncUseLoading()**: 0.076ms per operation (overhead)
|
|
35
|
+
- **overrideLoading()**: 0.011ms per operation
|
|
36
|
+
- **10 instances start/stop**: 0.269ms per cycle
|
|
37
|
+
|
|
38
|
+
All measurements are extremely fast, with even 1000 operations completing in milliseconds. Measurements based on actual benchmarks in test suite.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### 1. Setup the Loading Renderer
|
|
43
|
+
|
|
44
|
+
First, add the `LoadingRenderer` at the root of your application:
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { LoadingRenderer } from '@rokku-x/react-hook-loading-spinner';
|
|
48
|
+
|
|
49
|
+
function App() {
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<YourComponents />
|
|
53
|
+
<LoadingRenderer />
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 2. Use the Loading Hook
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { useLoading } from '@rokku-x/react-hook-loading-spinner';
|
|
63
|
+
|
|
64
|
+
function MyComponent() {
|
|
65
|
+
const { startLoading, stopLoading, isLoading } = useLoading();
|
|
66
|
+
|
|
67
|
+
const handleClick = async () => {
|
|
68
|
+
startLoading();
|
|
69
|
+
try {
|
|
70
|
+
await fetchData();
|
|
71
|
+
} finally {
|
|
72
|
+
stopLoading();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<button onClick={handleClick} disabled={isLoading}>
|
|
78
|
+
{isLoading ? 'Loading...' : 'Fetch Data'}
|
|
79
|
+
</button>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## API Reference
|
|
85
|
+
|
|
86
|
+
### LoadingRenderer
|
|
87
|
+
|
|
88
|
+
The main component that renders the loading spinner overlay. Must be mounted at the root level.
|
|
89
|
+
|
|
90
|
+
#### Props
|
|
91
|
+
|
|
92
|
+
| Prop | Type | Default | Description |
|
|
93
|
+
|------|------|---------|-------------|
|
|
94
|
+
| `loadingComponent` | `React.ComponentType \| React.ReactElement` | `LoadingCircle` | Custom loading component to display |
|
|
95
|
+
| `loadingComponentScale` | `number` | `1` | Scale factor for the loading component |
|
|
96
|
+
| `animationType` | `AnimationType` | `AnimationType.Spin` | Animation type: `'spin'`, `'fadeInOut'`, or `'none'` |
|
|
97
|
+
| `animationDuration` | `number` | `1` (spin) or `2` (fade) | Animation duration in seconds |
|
|
98
|
+
| `wrapperStyle` | `CSSProperties` | `undefined` | Inline styles for the dialog wrapper |
|
|
99
|
+
| `wrapperClassName` | `string` | `undefined` | CSS class for the dialog wrapper |
|
|
100
|
+
| `wrapperId` | `string` | `'loading-wrapper-{random}'` | Unique identifier for the wrapper |
|
|
101
|
+
| `animationWrapperStyle` | `CSSProperties` | `undefined` | Inline styles for the animation wrapper |
|
|
102
|
+
| `animationWrapperClassName` | `string` | `undefined` | CSS class for the animation wrapper |
|
|
103
|
+
| `animationWrapperId` | `string` | `undefined` | ID for the animation wrapper |
|
|
104
|
+
|
|
105
|
+
#### Built-in Loading Components
|
|
106
|
+
|
|
107
|
+
| Component | Description |
|
|
108
|
+
|-----------|-------------|
|
|
109
|
+
| `LoadingCircle` | Spinning circle spinner (default) |
|
|
110
|
+
| `LoadingPleaseWait` | "Please wait..." text |
|
|
111
|
+
|
|
112
|
+
### useLoading
|
|
113
|
+
|
|
114
|
+
Hook for managing loading state in your components.
|
|
115
|
+
|
|
116
|
+
#### Returns
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
{
|
|
120
|
+
startLoading: () => void,
|
|
121
|
+
stopLoading: () => void,
|
|
122
|
+
asyncUseLoading: <R>(promise: Promise<R>) => Promise<R>,
|
|
123
|
+
overrideLoading: (state: boolean | null) => void,
|
|
124
|
+
isLoading: boolean,
|
|
125
|
+
isLocalLoading: boolean
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Return Value | Type | Description |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `startLoading` | `() => void` | Increment the loading counter (starts loading) |
|
|
132
|
+
| `stopLoading` | `() => void` | Decrement the loading counter (stops loading when reaches 0) |
|
|
133
|
+
| `asyncUseLoading` | `<R>(promise: Promise<R>) => Promise<R>` | Wrap a promise to automatically manage loading state |
|
|
134
|
+
| `overrideLoading` | `(state: boolean \| null) => void` | Override the loading state (null = remove override) |
|
|
135
|
+
| `isLoading` | `boolean` | Global loading state (true if any component is loading) |
|
|
136
|
+
| `isLocalLoading` | `boolean` | Local loading state for this hook instance only |
|
|
137
|
+
|
|
138
|
+
### Loading Component
|
|
139
|
+
|
|
140
|
+
A component that triggers loading state based on a prop.
|
|
141
|
+
|
|
142
|
+
#### Props
|
|
143
|
+
|
|
144
|
+
| Prop | Type | Default | Description |
|
|
145
|
+
|------|------|---------|-------------|
|
|
146
|
+
| `isLoading` | `boolean` | `false` | Whether to show loading state |
|
|
147
|
+
|
|
148
|
+
#### Example
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { Loading } from 'react-hook-loading-spinner';
|
|
152
|
+
|
|
153
|
+
function MyComponent() {
|
|
154
|
+
const [fetching, setFetching] = useState(false);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
<Loading isLoading={fetching} />
|
|
159
|
+
{/* Your component */}
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### AnimationType
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
const AnimationType = {
|
|
169
|
+
Spin: 'spin',
|
|
170
|
+
FadeInOut: 'fadeInOut',
|
|
171
|
+
None: 'none',
|
|
172
|
+
} as const;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### loadingEventTarget
|
|
176
|
+
|
|
177
|
+
Event emitter for subscribing to loading state changes.
|
|
178
|
+
|
|
179
|
+
#### Events
|
|
180
|
+
|
|
181
|
+
| Event | Payload | Description |
|
|
182
|
+
|-------|---------|-------------|
|
|
183
|
+
| `'change'` | `{ isLoading: boolean, isOverrideState: boolean }` | Emitted whenever loading state changes |
|
|
184
|
+
| `'start'` | `null` | Emitted when loading starts (transitions from false to true) |
|
|
185
|
+
| `'stop'` | `null` | Emitted when loading stops (transitions from true to false) |
|
|
186
|
+
|
|
187
|
+
#### Example
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
import { loadingEventTarget } from 'react-hook-loading-spinner';
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const listener = loadingEventTarget.on('change', ({ isLoading }) => {
|
|
194
|
+
console.log('Loading state changed:', isLoading);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return () => listener.removeAllListeners();
|
|
198
|
+
}, []);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Examples
|
|
202
|
+
|
|
203
|
+
### Example 1: Basic Loading
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
import { useLoading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
207
|
+
|
|
208
|
+
function App() {
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
<BasicLoadingExample />
|
|
212
|
+
<LoadingRenderer />
|
|
213
|
+
</>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function BasicLoadingExample() {
|
|
218
|
+
const { startLoading, stopLoading } = useLoading();
|
|
219
|
+
|
|
220
|
+
const handleFetch = async () => {
|
|
221
|
+
startLoading();
|
|
222
|
+
try {
|
|
223
|
+
await fetch('https://api.example.com/data');
|
|
224
|
+
} finally {
|
|
225
|
+
stopLoading();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return <button onClick={handleFetch}>Fetch Data</button>;
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Example 2: Async Wrapper
|
|
234
|
+
|
|
235
|
+
The `asyncUseLoading` method automatically manages loading state for promises.
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
import { useLoading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
239
|
+
|
|
240
|
+
function AsyncExample() {
|
|
241
|
+
const { asyncUseLoading } = useLoading();
|
|
242
|
+
|
|
243
|
+
const handleFetch = async () => {
|
|
244
|
+
// Loading state is automatically managed
|
|
245
|
+
const data = await asyncUseLoading(
|
|
246
|
+
fetch('https://api.example.com/data').then(res => res.json())
|
|
247
|
+
);
|
|
248
|
+
console.log(data);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
return <button onClick={handleFetch}>Fetch Data</button>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function App() {
|
|
255
|
+
return (
|
|
256
|
+
<>
|
|
257
|
+
<AsyncExample />
|
|
258
|
+
<LoadingRenderer />
|
|
259
|
+
</>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Example 3: Multiple Loading States
|
|
265
|
+
|
|
266
|
+
Multiple components can start loading independently. The loading indicator stays visible until all have stopped.
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
import { useLoading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
270
|
+
|
|
271
|
+
function MultipleLoadingExample() {
|
|
272
|
+
const { asyncUseLoading } = useLoading();
|
|
273
|
+
|
|
274
|
+
const fetchUser = () => asyncUseLoading(
|
|
275
|
+
fetch('https://api.example.com/user').then(res => res.json())
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const fetchPosts = () => asyncUseLoading(
|
|
279
|
+
fetch('https://api.example.com/posts').then(res => res.json())
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const fetchAll = async () => {
|
|
283
|
+
// Both requests will show loading
|
|
284
|
+
// Loading stops when both complete
|
|
285
|
+
await Promise.all([fetchUser(), fetchPosts()]);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div>
|
|
290
|
+
<button onClick={fetchUser}>Fetch User</button>
|
|
291
|
+
<button onClick={fetchPosts}>Fetch Posts</button>
|
|
292
|
+
<button onClick={fetchAll}>Fetch All</button>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function App() {
|
|
298
|
+
return (
|
|
299
|
+
<>
|
|
300
|
+
<MultipleLoadingExample />
|
|
301
|
+
<LoadingRenderer />
|
|
302
|
+
</>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Example 4: Local Loading State
|
|
308
|
+
|
|
309
|
+
Track loading state for individual components without affecting global state visibility.
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
import { useLoading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
313
|
+
|
|
314
|
+
function LocalLoadingExample() {
|
|
315
|
+
const { startLoading, stopLoading, isLocalLoading } = useLoading();
|
|
316
|
+
|
|
317
|
+
const handleClick = async () => {
|
|
318
|
+
startLoading();
|
|
319
|
+
try {
|
|
320
|
+
await fetch('https://api.example.com/data');
|
|
321
|
+
} finally {
|
|
322
|
+
stopLoading();
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<button onClick={handleClick} disabled={isLocalLoading}>
|
|
328
|
+
{isLocalLoading ? 'Loading...' : 'Fetch Data'}
|
|
329
|
+
</button>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function App() {
|
|
334
|
+
return (
|
|
335
|
+
<>
|
|
336
|
+
<LocalLoadingExample />
|
|
337
|
+
<LoadingRenderer />
|
|
338
|
+
</>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Example 5: Override Loading State
|
|
344
|
+
|
|
345
|
+
Force loading state on or off, bypassing the reference counting.
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
import { useLoading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
349
|
+
|
|
350
|
+
function OverrideExample() {
|
|
351
|
+
const { overrideLoading, isLoading } = useLoading();
|
|
352
|
+
|
|
353
|
+
const forceLoading = () => overrideLoading(true);
|
|
354
|
+
const clearOverride = () => overrideLoading(null);
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<div>
|
|
358
|
+
<button onClick={forceLoading}>Force Loading</button>
|
|
359
|
+
<button onClick={clearOverride}>Clear Override</button>
|
|
360
|
+
<p>Loading: {isLoading ? 'Yes' : 'No'}</p>
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function App() {
|
|
366
|
+
return (
|
|
367
|
+
<>
|
|
368
|
+
<OverrideExample />
|
|
369
|
+
<LoadingRenderer />
|
|
370
|
+
</>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Example 6: Custom Loading Component
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
import { LoadingRenderer } from 'react-hook-loading-spinner';
|
|
379
|
+
|
|
380
|
+
const CustomSpinner = () => (
|
|
381
|
+
<div style={{
|
|
382
|
+
width: '100px',
|
|
383
|
+
height: '100px',
|
|
384
|
+
border: '10px solid #e0e0e0',
|
|
385
|
+
borderTop: '10px solid #3498db',
|
|
386
|
+
borderRadius: '50%',
|
|
387
|
+
}} />
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
function App() {
|
|
391
|
+
return (
|
|
392
|
+
<>
|
|
393
|
+
<YourComponents />
|
|
394
|
+
<LoadingRenderer
|
|
395
|
+
loadingComponent={CustomSpinner}
|
|
396
|
+
loadingComponentScale={1.5}
|
|
397
|
+
/>
|
|
398
|
+
</>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Example 7: Fade Animation
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
import { LoadingRenderer, AnimationType, LoadingPleaseWait } from 'react-hook-loading-spinner';
|
|
407
|
+
|
|
408
|
+
function App() {
|
|
409
|
+
return (
|
|
410
|
+
<>
|
|
411
|
+
<YourComponents />
|
|
412
|
+
<LoadingRenderer
|
|
413
|
+
loadingComponent={LoadingPleaseWait}
|
|
414
|
+
animationType={AnimationType.FadeInOut}
|
|
415
|
+
animationDuration={1.5}
|
|
416
|
+
/>
|
|
417
|
+
</>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Example 8: Custom Styling
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
import { LoadingRenderer, LoadingCircle } from 'react-hook-loading-spinner';
|
|
426
|
+
|
|
427
|
+
function App() {
|
|
428
|
+
return (
|
|
429
|
+
<>
|
|
430
|
+
<YourComponents />
|
|
431
|
+
<LoadingRenderer
|
|
432
|
+
loadingComponent={LoadingCircle}
|
|
433
|
+
wrapperStyle={{
|
|
434
|
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
435
|
+
backdropFilter: 'blur(5px)',
|
|
436
|
+
}}
|
|
437
|
+
animationWrapperStyle={{
|
|
438
|
+
padding: '40px',
|
|
439
|
+
backgroundColor: 'white',
|
|
440
|
+
borderRadius: '16px',
|
|
441
|
+
boxShadow: '0 10px 40px rgba(0,0,0,0.3)',
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
</>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Example 9: Loading Component Prop
|
|
450
|
+
|
|
451
|
+
Use the `<Loading>` component to trigger loading based on a boolean prop.
|
|
452
|
+
|
|
453
|
+
```tsx
|
|
454
|
+
import { Loading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
455
|
+
import { useState } from 'react';
|
|
456
|
+
|
|
457
|
+
function LoadingComponentExample() {
|
|
458
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
459
|
+
|
|
460
|
+
const handleFetch = async () => {
|
|
461
|
+
setIsFetching(true);
|
|
462
|
+
try {
|
|
463
|
+
await fetch('https://api.example.com/data');
|
|
464
|
+
} finally {
|
|
465
|
+
setIsFetching(false);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<div>
|
|
471
|
+
<Loading isLoading={isFetching} />
|
|
472
|
+
<button onClick={handleFetch}>Fetch Data</button>
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function App() {
|
|
478
|
+
return (
|
|
479
|
+
<>
|
|
480
|
+
<LoadingComponentExample />
|
|
481
|
+
<LoadingRenderer />
|
|
482
|
+
</>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### Example 10: Event Listener
|
|
488
|
+
|
|
489
|
+
Subscribe to loading state changes using the event system.
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
import { loadingEventTarget, useLoading, LoadingRenderer } from 'react-hook-loading-spinner';
|
|
493
|
+
import { useEffect, useState } from 'react';
|
|
494
|
+
|
|
495
|
+
function EventListenerExample() {
|
|
496
|
+
const { startLoading, stopLoading } = useLoading();
|
|
497
|
+
const [loadingLog, setLoadingLog] = useState<string[]>([]);
|
|
498
|
+
|
|
499
|
+
useEffect(() => {
|
|
500
|
+
const onChange = loadingEventTarget.on('change', ({ isLoading }) => {
|
|
501
|
+
setLoadingLog(prev => [...prev, `Changed: ${isLoading}`]);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const onStart = loadingEventTarget.on('start', () => {
|
|
505
|
+
setLoadingLog(prev => [...prev, 'Started!']);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const onStop = loadingEventTarget.on('stop', () => {
|
|
509
|
+
setLoadingLog(prev => [...prev, 'Stopped!']);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
return () => {
|
|
513
|
+
onChange.removeAllListeners();
|
|
514
|
+
onStart.removeAllListeners();
|
|
515
|
+
onStop.removeAllListeners();
|
|
516
|
+
};
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div>
|
|
521
|
+
<button onClick={startLoading}>Start</button>
|
|
522
|
+
<button onClick={stopLoading}>Stop</button>
|
|
523
|
+
<ul>
|
|
524
|
+
{loadingLog.map((log, i) => <li key={i}>{log}</li>)}
|
|
525
|
+
</ul>
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function App() {
|
|
531
|
+
return (
|
|
532
|
+
<>
|
|
533
|
+
<EventListenerExample />
|
|
534
|
+
<LoadingRenderer />
|
|
535
|
+
</>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
## How It Works
|
|
541
|
+
|
|
542
|
+
### Reference Counting
|
|
543
|
+
|
|
544
|
+
The library uses a reference counting system to manage loading state:
|
|
545
|
+
|
|
546
|
+
1. Each call to `startLoading()` increments a counter
|
|
547
|
+
2. Each call to `stopLoading()` decrements the counter
|
|
548
|
+
3. Loading is active when the counter > 0
|
|
549
|
+
4. Multiple components can independently manage loading
|
|
550
|
+
|
|
551
|
+
### Local Tracking
|
|
552
|
+
|
|
553
|
+
Each `useLoading()` hook instance maintains its own local counter:
|
|
554
|
+
|
|
555
|
+
- `isLocalLoading` reflects only this instance's loading state
|
|
556
|
+
- `startLoading()`/`stopLoading()` only affect the local counter
|
|
557
|
+
- Local counter prevents calling `stopLoading()` more times than `startLoading()`
|
|
558
|
+
|
|
559
|
+
### Global State
|
|
560
|
+
|
|
561
|
+
The global loading state is managed by Zustand:
|
|
562
|
+
|
|
563
|
+
- `isLoading` reflects whether ANY component is loading
|
|
564
|
+
- Shared across all hook instances
|
|
565
|
+
- Can be overridden with `overrideLoading()`
|
|
566
|
+
|
|
567
|
+
## Best Practices
|
|
568
|
+
|
|
569
|
+
1. **Always pair start/stop**: Use try/finally to ensure loading stops even on errors
|
|
570
|
+
```tsx
|
|
571
|
+
const { startLoading, stopLoading } = useLoading();
|
|
572
|
+
|
|
573
|
+
const fetchData = async () => {
|
|
574
|
+
startLoading();
|
|
575
|
+
try {
|
|
576
|
+
await api.fetchData();
|
|
577
|
+
} finally {
|
|
578
|
+
stopLoading(); // Always called, even on error
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
2. **Use async wrapper**: Let the library handle cleanup automatically
|
|
584
|
+
```tsx
|
|
585
|
+
const { asyncUseLoading } = useLoading();
|
|
586
|
+
|
|
587
|
+
const fetchData = () => asyncUseLoading(api.fetchData());
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
3. **Disable buttons during loading**: Prevent duplicate requests
|
|
591
|
+
```tsx
|
|
592
|
+
const { isLocalLoading } = useLoading();
|
|
593
|
+
|
|
594
|
+
<button disabled={isLocalLoading} onClick={fetchData}>
|
|
595
|
+
Fetch
|
|
596
|
+
</button>
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
4. **Clean up event listeners**: Remove listeners in useEffect cleanup
|
|
600
|
+
```tsx
|
|
601
|
+
useEffect(() => {
|
|
602
|
+
const listener = loadingEventTarget.on('change', handleChange);
|
|
603
|
+
return () => listener.removeAllListeners();
|
|
604
|
+
}, []);
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
## TypeScript
|
|
608
|
+
|
|
609
|
+
The library is written in TypeScript and provides full type definitions.
|
|
610
|
+
|
|
611
|
+
```typescript
|
|
612
|
+
import { useLoading, AnimationType } from 'react-hook-loading-spinner';
|
|
613
|
+
import type { AnimationTypeType } from 'react-hook-loading-spinner';
|
|
614
|
+
|
|
615
|
+
const { startLoading, stopLoading, isLoading } = useLoading();
|
|
616
|
+
|
|
617
|
+
const animation: AnimationTypeType = AnimationType.Spin;
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
## Browser Support
|
|
621
|
+
|
|
622
|
+
- Modern browsers (Chrome, Firefox, Safari, Edge)
|
|
623
|
+
- Requires support for:
|
|
624
|
+
- React 18+
|
|
625
|
+
- ES6+ features
|
|
626
|
+
- `<dialog>` element (with polyfill if needed)
|
|
627
|
+
- CSS animations
|
|
628
|
+
|
|
629
|
+
## License
|
|
630
|
+
|
|
631
|
+
MIT
|
|
632
|
+
|
|
633
|
+
## Contributing
|
|
634
|
+
|
|
635
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
636
|
+
|
|
637
|
+
## Repository
|
|
638
|
+
|
|
639
|
+
[https://github.com/rokku-x/react-hook-loading-spinner](https://github.com/rokku-x/react-hook-loading-spinner)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
export declare const AnimationType: {
|
|
3
|
+
readonly Spin: "spin";
|
|
4
|
+
readonly FadeInOut: "fadeInOut";
|
|
5
|
+
readonly None: "none";
|
|
6
|
+
};
|
|
7
|
+
export type AnimationType = (typeof AnimationType)[keyof typeof AnimationType];
|
|
8
|
+
export declare const LoadingCircle: React.FC;
|
|
9
|
+
export declare const LoadingPleaseWait: React.FC;
|
|
10
|
+
export default function LoadingRenderer({ loadingComponent, loadingComponentScale, animationType, animationDuration, wrapperStyle, wrapperClassName, wrapperId, animationWrapperStyle, animationWrapperClassName, animationWrapperId }: {
|
|
11
|
+
loadingComponent?: React.ComponentType | React.ReactElement;
|
|
12
|
+
loadingComponentScale?: number;
|
|
13
|
+
animationType?: AnimationType;
|
|
14
|
+
animationDuration?: number;
|
|
15
|
+
wrapperStyle?: React.CSSProperties;
|
|
16
|
+
wrapperClassName?: string;
|
|
17
|
+
wrapperId?: string;
|
|
18
|
+
animationWrapperStyle?: React.CSSProperties;
|
|
19
|
+
animationWrapperClassName?: string;
|
|
20
|
+
animationWrapperId?: string;
|
|
21
|
+
}): React.ReactPortal | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { default as EventEmitter } from '../utils/EventEmitter';
|
|
2
|
+
type LoadingEvents = {
|
|
3
|
+
change: {
|
|
4
|
+
isLoading: boolean;
|
|
5
|
+
isOverrideState: boolean;
|
|
6
|
+
} | null;
|
|
7
|
+
start: null;
|
|
8
|
+
stop: null;
|
|
9
|
+
};
|
|
10
|
+
export declare const loadingEventTarget: EventEmitter<LoadingEvents>;
|
|
11
|
+
export default function useLoading(): {
|
|
12
|
+
overrideLoading: (state: boolean | null) => void;
|
|
13
|
+
startLoading: () => void;
|
|
14
|
+
stopLoading: () => void;
|
|
15
|
+
readonly isLocalLoading: boolean;
|
|
16
|
+
asyncUseLoading: <R, _ extends any[]>(asyncFunction: Promise<R>) => Promise<R>;
|
|
17
|
+
readonly isLoading: boolean;
|
|
18
|
+
};
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const m=require("react"),h=require("react/jsx-runtime"),N=require("react-dom"),I=n=>{let t;const e=new Set,i=(f,r)=>{const d=typeof f=="function"?f(t):f;if(!Object.is(d,t)){const g=t;t=r??(typeof d!="object"||d===null)?d:Object.assign({},t,d),e.forEach(u=>u(t,g))}},o=()=>t,s={setState:i,getState:o,getInitialState:()=>p,subscribe:f=>(e.add(f),()=>e.delete(f))},p=t=n(i,o,s);return s},D=(n=>n?I(n):I),F=n=>n;function M(n,t=F){const e=m.useSyncExternalStore(n.subscribe,m.useCallback(()=>t(n.getState()),[n,t]),m.useCallback(()=>t(n.getInitialState()),[n,t]));return m.useDebugValue(e),e}const j=n=>{const t=D(n),e=i=>M(t,i);return Object.assign(e,t),e},P=(n=>n?j(n):j),T={BASE_URL:"/",DEV:!1,MODE:"production",PROD:!0,SSR:!1},C=new Map,x=n=>{const t=C.get(n);return t?Object.fromEntries(Object.entries(t.stores).map(([e,i])=>[e,i.getState()])):{}},$=(n,t,e)=>{if(n===void 0)return{type:"untracked",connection:t.connect(e)};const i=C.get(e.name);if(i)return{type:"tracked",store:n,...i};const o={connection:t.connect(e),stores:{}};return C.set(e.name,o),{type:"tracked",store:n,...o}},J=(n,t)=>{if(t===void 0)return;const e=C.get(n);e&&(delete e.stores[t],Object.keys(e.stores).length===0&&C.delete(n))},U=n=>{var t,e;if(!n)return;const i=n.split(`
|
|
2
|
+
`),o=i.findIndex(v=>v.includes("api.setState"));if(o<0)return;const l=((t=i[o+1])==null?void 0:t.trim())||"";return(e=/.+ (.+) .+/.exec(l))==null?void 0:e[1]},z=(n,t={})=>(e,i,o)=>{const{enabled:l,anonymousActionType:v,store:s,...p}=t;let f;try{f=(l??(T?"production":void 0)!=="production")&&window.__REDUX_DEVTOOLS_EXTENSION__}catch{}if(!f)return n(e,i,o);const{connection:r,...d}=$(s,f,p);let g=!0;o.setState=((c,L,a)=>{const S=e(c,L);if(!g)return S;const O=a===void 0?{type:v||U(new Error().stack)||"anonymous"}:typeof a=="string"?{type:a}:a;return s===void 0?(r?.send(O,i()),S):(r?.send({...O,type:`${s}/${O.type}`},{...x(p.name),[s]:o.getState()}),S)}),o.devtools={cleanup:()=>{r&&typeof r.unsubscribe=="function"&&r.unsubscribe(),J(p.name,s)}};const u=(...c)=>{const L=g;g=!1,e(...c),g=L},E=n(o.setState,i,o);if(d.type==="untracked"?r?.init(E):(d.stores[d.store]=o,r?.init(Object.fromEntries(Object.entries(d.stores).map(([c,L])=>[c,c===d.store?E:L.getState()])))),o.dispatchFromDevtools&&typeof o.dispatch=="function"){let c=!1;const L=o.dispatch;o.dispatch=(...a)=>{(T?"production":void 0)!=="production"&&a[0].type==="__setState"&&!c&&(console.warn('[zustand devtools middleware] "__setState" action type is reserved to set state from the devtools. Avoid using it.'),c=!0),L(...a)}}return r.subscribe(c=>{var L;switch(c.type){case"ACTION":if(typeof c.payload!="string"){console.error("[zustand devtools middleware] Unsupported action format");return}return _(c.payload,a=>{if(a.type==="__setState"){if(s===void 0){u(a.state);return}Object.keys(a.state).length!==1&&console.error(`
|
|
3
|
+
[zustand devtools middleware] Unsupported __setState action format.
|
|
4
|
+
When using 'store' option in devtools(), the 'state' should have only one key, which is a value of 'store' that was passed in devtools(),
|
|
5
|
+
and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } }
|
|
6
|
+
`);const S=a.state[s];if(S==null)return;JSON.stringify(o.getState())!==JSON.stringify(S)&&u(S);return}o.dispatchFromDevtools&&typeof o.dispatch=="function"&&o.dispatch(a)});case"DISPATCH":switch(c.payload.type){case"RESET":return u(E),s===void 0?r?.init(o.getState()):r?.init(x(p.name));case"COMMIT":if(s===void 0){r?.init(o.getState());return}return r?.init(x(p.name));case"ROLLBACK":return _(c.state,a=>{if(s===void 0){u(a),r?.init(o.getState());return}u(a[s]),r?.init(x(p.name))});case"JUMP_TO_STATE":case"JUMP_TO_ACTION":return _(c.state,a=>{if(s===void 0){u(a);return}JSON.stringify(o.getState())!==JSON.stringify(a[s])&&u(a[s])});case"IMPORT_STATE":{const{nextLiftedState:a}=c.payload,S=(L=a.computedStates.slice(-1)[0])==null?void 0:L.state;if(!S)return;u(s===void 0?S:S[s]),r?.send(null,a);return}case"PAUSE_RECORDING":return g=!g}return}}),E},W=z,_=(n,t)=>{let e;try{e=JSON.parse(n)}catch(i){console.error("[zustand devtools middleware] Could not parse the received json",i)}e!==void 0&&t(e)};class R extends EventTarget{constructor(){super(...arguments),this.controller=new AbortController}on(t,e){return this.addEventListener(t,i=>e(i.detail),{signal:this.controller.signal}),this}once(t,e){return this.addEventListener(t,i=>e(i.detail),{signal:this.controller.signal,once:!0}),this}emit(t,e){return this.dispatchEvent(new CustomEvent(t,{detail:e}))}removeAllListeners(){this.controller.abort(),this.controller=new AbortController}}const b=new R,V=P()(W((n,t)=>({loadingCount:0,overrideState:null,localCounters:{},isLoading(){return t().overrideState??t().loadingCount>0},actions:{isGlobalLoading(){return t().isLoading()},startLoading:e=>{const i=t().isLoading();n(l=>({loadingCount:l.loadingCount+1})),e&&n(l=>({localCounters:{...l.localCounters,[e]:(l.localCounters[e]??0)+1}}));const o=t().isLoading();o&&!i&&b.emit("start",null),b.emit("change",{isLoading:o,isOverrideState:t().overrideState!==null})},stopLoading:e=>{const i=t().isLoading(),o=e?t().localCounters[e]??0:0;e&&o>0&&n(v=>({loadingCount:Math.max(0,v.loadingCount-1),localCounters:{...v.localCounters,[e]:Math.max(0,v.localCounters[e]-1)}}));const l=t().isLoading();!l&&i&&b.emit("stop",null),b.emit("change",{isLoading:l,isOverrideState:t().overrideState!==null})},overrideLoading:e=>{const i=t().isLoading();n({overrideState:e});const o=t().isLoading();o&&!i?b.emit("start",null):!o&&i&&b.emit("stop",null),b.emit("change",{isLoading:o,isOverrideState:e!==null})},getLocalCounter:e=>t().localCounters[e]??0,isLocalLoading:e=>(t().localCounters[e]??0)>0}})));function w(){const n=m.useId(),{actions:t}=V(l=>l),e=()=>{t.startLoading(n)},i=()=>{t.isLocalLoading(n)&&t.stopLoading(n)},o=async l=>{e();try{return await l}finally{i()}};return{overrideLoading:t.overrideLoading,startLoading:e,stopLoading:i,get isLocalLoading(){return t.isLocalLoading(n)},asyncUseLoading:o,get isLoading(){return t.isGlobalLoading()}}}const y={Spin:"spin",FadeInOut:"fadeInOut",None:"none"},q=({scale:n=1,animationType:t=y.Spin,animationDuration:e,children:i,style:o,className:l,id:v,prefix:s})=>{const p=t,f=t===y.Spin?1:t===y.FadeInOut?2:0,r=e||f,d=t===y.Spin?"linear":t===y.FadeInOut?"ease-in-out":"linear",g=t===y.None?"none":`${s}-${p} ${r}s ${d} infinite`;return h.jsx("div",{style:{animation:g,...n!==1?{zoom:n}:{},...o},className:l,id:v,children:i})},A=n=>h.jsx("div",{id:"loading-circle",style:{width:"90px",height:"90px",border:"15px solid #f3f3f3",borderTop:"15px solid #009b4bff",borderRadius:"50%",boxSizing:"border-box"},...n}),k=n=>h.jsx("div",{style:{padding:"20px",fontSize:"25px",color:"#333",fontFamily:"system-ui, sans-serif"},...n,children:"Please wait..."});function B({loadingComponent:n,loadingComponentScale:t=1,animationType:e=y.Spin,animationDuration:i,wrapperStyle:o,wrapperClassName:l,wrapperId:v,animationWrapperStyle:s,animationWrapperClassName:p,animationWrapperId:f}){n=n||(e===y.Spin?A:k);const r=m.useRef(Math.random().toString(36).substring(2,6).replace(/[0-9]/g,"")),{isLoading:d}=w(),g=m.useRef(null);m.useEffect(()=>{d?(g.current?.showModal(),document.body.setAttribute("inert","")):(g.current?.close(),document.body.removeAttribute("inert"))},[d]);const u=v||"loading-wrapper-"+r.current;return d?N.createPortal(h.jsxs(h.Fragment,{children:[h.jsx("style",{children:`
|
|
7
|
+
dialog#${u}[open] {
|
|
8
|
+
display: flex !important;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
align-items: center;
|
|
11
|
+
width: 100vw;
|
|
12
|
+
height: 100vh;
|
|
13
|
+
max-width: 100%;
|
|
14
|
+
max-height: 100%;
|
|
15
|
+
}
|
|
16
|
+
@keyframes ${r.current}-spin {
|
|
17
|
+
0% { transform: rotate(0deg); }
|
|
18
|
+
100% { transform: rotate(360deg); }
|
|
19
|
+
}
|
|
20
|
+
@keyframes ${r.current}-fadeInOut {
|
|
21
|
+
0%, 100% { opacity: 0.2; }
|
|
22
|
+
50% { opacity: 1; }
|
|
23
|
+
}
|
|
24
|
+
body:has(dialog#${u}[open]) {
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
}
|
|
27
|
+
body {
|
|
28
|
+
scrollbar-gutter: stable;
|
|
29
|
+
}
|
|
30
|
+
`}),h.jsx("dialog",{ref:g,style:{border:"none",padding:0,backgroundColor:"rgba(0, 0, 0, 0.5)",backdropFilter:"blur(2px)",...o},className:l,id:u,children:h.jsx(q,{scale:t,animationType:e,animationDuration:i,style:s,className:p,id:f,prefix:r.current,children:m.isValidElement(n)?n:m.createElement(n)})})]}),document.body):null}function G({isLoading:n=!1}){const{startLoading:t,stopLoading:e}=w();return m.useEffect(()=>(n?t():e(),()=>{n&&e()}),[n]),null}exports.AnimationType=y;exports.EventEmitter=R;exports.Loading=G;exports.LoadingCircle=A;exports.LoadingPleaseWait=k;exports.LoadingRenderer=B;exports.loadingEventTarget=b;exports.useLoading=w;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as useLoading } from './hooks/useLoading';
|
|
2
|
+
export { loadingEventTarget } from './hooks/useLoading';
|
|
3
|
+
export { default as LoadingRenderer } from './components/LoadingRenderer';
|
|
4
|
+
export { LoadingCircle, LoadingPleaseWait, AnimationType } from './components/LoadingRenderer';
|
|
5
|
+
export type { AnimationType as AnimationTypeType } from './components/LoadingRenderer';
|
|
6
|
+
export { default as Loading } from './components/Loading';
|
|
7
|
+
export { default as EventEmitter } from './utils/EventEmitter';
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import y, { useId as N, useRef as x, useEffect as k } from "react";
|
|
3
|
+
import { jsxs as j, Fragment as D, jsx as L } from "react/jsx-runtime";
|
|
4
|
+
import { createPortal as F } from "react-dom";
|
|
5
|
+
const I = (n) => {
|
|
6
|
+
let t;
|
|
7
|
+
const e = /* @__PURE__ */ new Set(), r = (f, i) => {
|
|
8
|
+
const d = typeof f == "function" ? f(t) : f;
|
|
9
|
+
if (!Object.is(d, t)) {
|
|
10
|
+
const g = t;
|
|
11
|
+
t = i ?? (typeof d != "object" || d === null) ? d : Object.assign({}, t, d), e.forEach((u) => u(t, g));
|
|
12
|
+
}
|
|
13
|
+
}, o = () => t, s = { setState: r, getState: o, getInitialState: () => p, subscribe: (f) => (e.add(f), () => e.delete(f)) }, p = t = n(r, o, s);
|
|
14
|
+
return s;
|
|
15
|
+
}, M = ((n) => n ? I(n) : I), $ = (n) => n;
|
|
16
|
+
function P(n, t = $) {
|
|
17
|
+
const e = y.useSyncExternalStore(
|
|
18
|
+
n.subscribe,
|
|
19
|
+
y.useCallback(() => t(n.getState()), [n, t]),
|
|
20
|
+
y.useCallback(() => t(n.getInitialState()), [n, t])
|
|
21
|
+
);
|
|
22
|
+
return y.useDebugValue(e), e;
|
|
23
|
+
}
|
|
24
|
+
const T = (n) => {
|
|
25
|
+
const t = M(n), e = (r) => P(t, r);
|
|
26
|
+
return Object.assign(e, t), e;
|
|
27
|
+
}, J = ((n) => n ? T(n) : T), R = { BASE_URL: "/", DEV: !1, MODE: "production", PROD: !0, SSR: !1 }, C = /* @__PURE__ */ new Map(), w = (n) => {
|
|
28
|
+
const t = C.get(n);
|
|
29
|
+
return t ? Object.fromEntries(
|
|
30
|
+
Object.entries(t.stores).map(([e, r]) => [e, r.getState()])
|
|
31
|
+
) : {};
|
|
32
|
+
}, U = (n, t, e) => {
|
|
33
|
+
if (n === void 0)
|
|
34
|
+
return {
|
|
35
|
+
type: "untracked",
|
|
36
|
+
connection: t.connect(e)
|
|
37
|
+
};
|
|
38
|
+
const r = C.get(e.name);
|
|
39
|
+
if (r)
|
|
40
|
+
return { type: "tracked", store: n, ...r };
|
|
41
|
+
const o = {
|
|
42
|
+
connection: t.connect(e),
|
|
43
|
+
stores: {}
|
|
44
|
+
};
|
|
45
|
+
return C.set(e.name, o), { type: "tracked", store: n, ...o };
|
|
46
|
+
}, z = (n, t) => {
|
|
47
|
+
if (t === void 0) return;
|
|
48
|
+
const e = C.get(n);
|
|
49
|
+
e && (delete e.stores[t], Object.keys(e.stores).length === 0 && C.delete(n));
|
|
50
|
+
}, V = (n) => {
|
|
51
|
+
var t, e;
|
|
52
|
+
if (!n) return;
|
|
53
|
+
const r = n.split(`
|
|
54
|
+
`), o = r.findIndex(
|
|
55
|
+
(v) => v.includes("api.setState")
|
|
56
|
+
);
|
|
57
|
+
if (o < 0) return;
|
|
58
|
+
const l = ((t = r[o + 1]) == null ? void 0 : t.trim()) || "";
|
|
59
|
+
return (e = /.+ (.+) .+/.exec(l)) == null ? void 0 : e[1];
|
|
60
|
+
}, W = (n, t = {}) => (e, r, o) => {
|
|
61
|
+
const { enabled: l, anonymousActionType: v, store: s, ...p } = t;
|
|
62
|
+
let f;
|
|
63
|
+
try {
|
|
64
|
+
f = (l ?? (R ? "production" : void 0) !== "production") && window.__REDUX_DEVTOOLS_EXTENSION__;
|
|
65
|
+
} catch {
|
|
66
|
+
}
|
|
67
|
+
if (!f)
|
|
68
|
+
return n(e, r, o);
|
|
69
|
+
const { connection: i, ...d } = U(s, f, p);
|
|
70
|
+
let g = !0;
|
|
71
|
+
o.setState = ((c, m, a) => {
|
|
72
|
+
const S = e(c, m);
|
|
73
|
+
if (!g) return S;
|
|
74
|
+
const O = a === void 0 ? {
|
|
75
|
+
type: v || V(new Error().stack) || "anonymous"
|
|
76
|
+
} : typeof a == "string" ? { type: a } : a;
|
|
77
|
+
return s === void 0 ? (i?.send(O, r()), S) : (i?.send(
|
|
78
|
+
{
|
|
79
|
+
...O,
|
|
80
|
+
type: `${s}/${O.type}`
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
...w(p.name),
|
|
84
|
+
[s]: o.getState()
|
|
85
|
+
}
|
|
86
|
+
), S);
|
|
87
|
+
}), o.devtools = {
|
|
88
|
+
cleanup: () => {
|
|
89
|
+
i && typeof i.unsubscribe == "function" && i.unsubscribe(), z(p.name, s);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const u = (...c) => {
|
|
93
|
+
const m = g;
|
|
94
|
+
g = !1, e(...c), g = m;
|
|
95
|
+
}, _ = n(o.setState, r, o);
|
|
96
|
+
if (d.type === "untracked" ? i?.init(_) : (d.stores[d.store] = o, i?.init(
|
|
97
|
+
Object.fromEntries(
|
|
98
|
+
Object.entries(d.stores).map(([c, m]) => [
|
|
99
|
+
c,
|
|
100
|
+
c === d.store ? _ : m.getState()
|
|
101
|
+
])
|
|
102
|
+
)
|
|
103
|
+
)), o.dispatchFromDevtools && typeof o.dispatch == "function") {
|
|
104
|
+
let c = !1;
|
|
105
|
+
const m = o.dispatch;
|
|
106
|
+
o.dispatch = (...a) => {
|
|
107
|
+
(R ? "production" : void 0) !== "production" && a[0].type === "__setState" && !c && (console.warn(
|
|
108
|
+
'[zustand devtools middleware] "__setState" action type is reserved to set state from the devtools. Avoid using it.'
|
|
109
|
+
), c = !0), m(...a);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return i.subscribe((c) => {
|
|
113
|
+
var m;
|
|
114
|
+
switch (c.type) {
|
|
115
|
+
case "ACTION":
|
|
116
|
+
if (typeof c.payload != "string") {
|
|
117
|
+
console.error(
|
|
118
|
+
"[zustand devtools middleware] Unsupported action format"
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
return E(
|
|
123
|
+
c.payload,
|
|
124
|
+
(a) => {
|
|
125
|
+
if (a.type === "__setState") {
|
|
126
|
+
if (s === void 0) {
|
|
127
|
+
u(a.state);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
Object.keys(a.state).length !== 1 && console.error(
|
|
131
|
+
`
|
|
132
|
+
[zustand devtools middleware] Unsupported __setState action format.
|
|
133
|
+
When using 'store' option in devtools(), the 'state' should have only one key, which is a value of 'store' that was passed in devtools(),
|
|
134
|
+
and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } }
|
|
135
|
+
`
|
|
136
|
+
);
|
|
137
|
+
const S = a.state[s];
|
|
138
|
+
if (S == null)
|
|
139
|
+
return;
|
|
140
|
+
JSON.stringify(o.getState()) !== JSON.stringify(S) && u(S);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
o.dispatchFromDevtools && typeof o.dispatch == "function" && o.dispatch(a);
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
case "DISPATCH":
|
|
147
|
+
switch (c.payload.type) {
|
|
148
|
+
case "RESET":
|
|
149
|
+
return u(_), s === void 0 ? i?.init(o.getState()) : i?.init(w(p.name));
|
|
150
|
+
case "COMMIT":
|
|
151
|
+
if (s === void 0) {
|
|
152
|
+
i?.init(o.getState());
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
return i?.init(w(p.name));
|
|
156
|
+
case "ROLLBACK":
|
|
157
|
+
return E(c.state, (a) => {
|
|
158
|
+
if (s === void 0) {
|
|
159
|
+
u(a), i?.init(o.getState());
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
u(a[s]), i?.init(w(p.name));
|
|
163
|
+
});
|
|
164
|
+
case "JUMP_TO_STATE":
|
|
165
|
+
case "JUMP_TO_ACTION":
|
|
166
|
+
return E(c.state, (a) => {
|
|
167
|
+
if (s === void 0) {
|
|
168
|
+
u(a);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
JSON.stringify(o.getState()) !== JSON.stringify(a[s]) && u(a[s]);
|
|
172
|
+
});
|
|
173
|
+
case "IMPORT_STATE": {
|
|
174
|
+
const { nextLiftedState: a } = c.payload, S = (m = a.computedStates.slice(-1)[0]) == null ? void 0 : m.state;
|
|
175
|
+
if (!S) return;
|
|
176
|
+
u(s === void 0 ? S : S[s]), i?.send(
|
|
177
|
+
null,
|
|
178
|
+
// FIXME no-any
|
|
179
|
+
a
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
case "PAUSE_RECORDING":
|
|
184
|
+
return g = !g;
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}), _;
|
|
189
|
+
}, B = W, E = (n, t) => {
|
|
190
|
+
let e;
|
|
191
|
+
try {
|
|
192
|
+
e = JSON.parse(n);
|
|
193
|
+
} catch (r) {
|
|
194
|
+
console.error(
|
|
195
|
+
"[zustand devtools middleware] Could not parse the received json",
|
|
196
|
+
r
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
e !== void 0 && t(e);
|
|
200
|
+
};
|
|
201
|
+
class G extends EventTarget {
|
|
202
|
+
constructor() {
|
|
203
|
+
super(...arguments), this.controller = new AbortController();
|
|
204
|
+
}
|
|
205
|
+
on(t, e) {
|
|
206
|
+
return this.addEventListener(
|
|
207
|
+
t,
|
|
208
|
+
(r) => e(r.detail),
|
|
209
|
+
{ signal: this.controller.signal }
|
|
210
|
+
), this;
|
|
211
|
+
}
|
|
212
|
+
once(t, e) {
|
|
213
|
+
return this.addEventListener(
|
|
214
|
+
t,
|
|
215
|
+
(r) => e(r.detail),
|
|
216
|
+
{
|
|
217
|
+
signal: this.controller.signal,
|
|
218
|
+
once: !0
|
|
219
|
+
}
|
|
220
|
+
), this;
|
|
221
|
+
}
|
|
222
|
+
emit(t, e) {
|
|
223
|
+
return this.dispatchEvent(new CustomEvent(t, { detail: e }));
|
|
224
|
+
}
|
|
225
|
+
removeAllListeners() {
|
|
226
|
+
this.controller.abort(), this.controller = new AbortController();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const b = new G(), X = J()(B((n, t) => ({
|
|
230
|
+
loadingCount: 0,
|
|
231
|
+
overrideState: null,
|
|
232
|
+
localCounters: {},
|
|
233
|
+
isLoading() {
|
|
234
|
+
return t().overrideState ?? t().loadingCount > 0;
|
|
235
|
+
},
|
|
236
|
+
actions: {
|
|
237
|
+
isGlobalLoading() {
|
|
238
|
+
return t().isLoading();
|
|
239
|
+
},
|
|
240
|
+
startLoading: (e) => {
|
|
241
|
+
const r = t().isLoading();
|
|
242
|
+
n((l) => ({ loadingCount: l.loadingCount + 1 })), e && n((l) => ({
|
|
243
|
+
localCounters: {
|
|
244
|
+
...l.localCounters,
|
|
245
|
+
[e]: (l.localCounters[e] ?? 0) + 1
|
|
246
|
+
}
|
|
247
|
+
}));
|
|
248
|
+
const o = t().isLoading();
|
|
249
|
+
o && !r && b.emit("start", null), b.emit("change", { isLoading: o, isOverrideState: t().overrideState !== null });
|
|
250
|
+
},
|
|
251
|
+
stopLoading: (e) => {
|
|
252
|
+
const r = t().isLoading(), o = e ? t().localCounters[e] ?? 0 : 0;
|
|
253
|
+
e && o > 0 && n((v) => ({
|
|
254
|
+
loadingCount: Math.max(0, v.loadingCount - 1),
|
|
255
|
+
localCounters: {
|
|
256
|
+
...v.localCounters,
|
|
257
|
+
[e]: Math.max(0, v.localCounters[e] - 1)
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
const l = t().isLoading();
|
|
261
|
+
!l && r && b.emit("stop", null), b.emit("change", { isLoading: l, isOverrideState: t().overrideState !== null });
|
|
262
|
+
},
|
|
263
|
+
overrideLoading: (e) => {
|
|
264
|
+
const r = t().isLoading();
|
|
265
|
+
n({ overrideState: e });
|
|
266
|
+
const o = t().isLoading();
|
|
267
|
+
o && !r ? b.emit("start", null) : !o && r && b.emit("stop", null), b.emit("change", { isLoading: o, isOverrideState: e !== null });
|
|
268
|
+
},
|
|
269
|
+
getLocalCounter: (e) => t().localCounters[e] ?? 0,
|
|
270
|
+
isLocalLoading: (e) => (t().localCounters[e] ?? 0) > 0
|
|
271
|
+
}
|
|
272
|
+
})));
|
|
273
|
+
function A() {
|
|
274
|
+
const n = N(), { actions: t } = X((l) => l), e = () => {
|
|
275
|
+
t.startLoading(n);
|
|
276
|
+
}, r = () => {
|
|
277
|
+
t.isLocalLoading(n) && t.stopLoading(n);
|
|
278
|
+
}, o = async (l) => {
|
|
279
|
+
e();
|
|
280
|
+
try {
|
|
281
|
+
return await l;
|
|
282
|
+
} finally {
|
|
283
|
+
r();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
overrideLoading: t.overrideLoading,
|
|
288
|
+
startLoading: e,
|
|
289
|
+
stopLoading: r,
|
|
290
|
+
get isLocalLoading() {
|
|
291
|
+
return t.isLocalLoading(n);
|
|
292
|
+
},
|
|
293
|
+
asyncUseLoading: o,
|
|
294
|
+
get isLoading() {
|
|
295
|
+
return t.isGlobalLoading();
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const h = {
|
|
300
|
+
Spin: "spin",
|
|
301
|
+
FadeInOut: "fadeInOut",
|
|
302
|
+
None: "none"
|
|
303
|
+
}, H = ({ scale: n = 1, animationType: t = h.Spin, animationDuration: e, children: r, style: o, className: l, id: v, prefix: s }) => {
|
|
304
|
+
const p = t, f = t === h.Spin ? 1 : t === h.FadeInOut ? 2 : 0, i = e || f, d = t === h.Spin ? "linear" : t === h.FadeInOut ? "ease-in-out" : "linear", g = t === h.None ? "none" : `${s}-${p} ${i}s ${d} infinite`;
|
|
305
|
+
return /* @__PURE__ */ L("div", { style: { animation: g, ...n !== 1 ? { zoom: n } : {}, ...o }, className: l, id: v, children: r });
|
|
306
|
+
}, K = (n) => /* @__PURE__ */ L("div", { id: "loading-circle", style: { width: "90px", height: "90px", border: "15px solid #f3f3f3", borderTop: "15px solid #009b4bff", borderRadius: "50%", boxSizing: "border-box" }, ...n }), q = (n) => /* @__PURE__ */ L("div", { style: { padding: "20px", fontSize: "25px", color: "#333", fontFamily: "system-ui, sans-serif" }, ...n, children: "Please wait..." });
|
|
307
|
+
function tt({
|
|
308
|
+
loadingComponent: n,
|
|
309
|
+
loadingComponentScale: t = 1,
|
|
310
|
+
animationType: e = h.Spin,
|
|
311
|
+
animationDuration: r,
|
|
312
|
+
wrapperStyle: o,
|
|
313
|
+
wrapperClassName: l,
|
|
314
|
+
wrapperId: v,
|
|
315
|
+
animationWrapperStyle: s,
|
|
316
|
+
animationWrapperClassName: p,
|
|
317
|
+
animationWrapperId: f
|
|
318
|
+
}) {
|
|
319
|
+
n = n || (e === h.Spin ? K : q);
|
|
320
|
+
const i = x(Math.random().toString(36).substring(2, 6).replace(/[0-9]/g, "")), { isLoading: d } = A(), g = x(null);
|
|
321
|
+
k(() => {
|
|
322
|
+
d ? (g.current?.showModal(), document.body.setAttribute("inert", "")) : (g.current?.close(), document.body.removeAttribute("inert"));
|
|
323
|
+
}, [d]);
|
|
324
|
+
const u = v || "loading-wrapper-" + i.current;
|
|
325
|
+
return d ? F(
|
|
326
|
+
/* @__PURE__ */ j(D, { children: [
|
|
327
|
+
/* @__PURE__ */ L("style", { children: `
|
|
328
|
+
dialog#${u}[open] {
|
|
329
|
+
display: flex !important;
|
|
330
|
+
justify-content: center;
|
|
331
|
+
align-items: center;
|
|
332
|
+
width: 100vw;
|
|
333
|
+
height: 100vh;
|
|
334
|
+
max-width: 100%;
|
|
335
|
+
max-height: 100%;
|
|
336
|
+
}
|
|
337
|
+
@keyframes ${i.current}-spin {
|
|
338
|
+
0% { transform: rotate(0deg); }
|
|
339
|
+
100% { transform: rotate(360deg); }
|
|
340
|
+
}
|
|
341
|
+
@keyframes ${i.current}-fadeInOut {
|
|
342
|
+
0%, 100% { opacity: 0.2; }
|
|
343
|
+
50% { opacity: 1; }
|
|
344
|
+
}
|
|
345
|
+
body:has(dialog#${u}[open]) {
|
|
346
|
+
overflow: hidden;
|
|
347
|
+
}
|
|
348
|
+
body {
|
|
349
|
+
scrollbar-gutter: stable;
|
|
350
|
+
}
|
|
351
|
+
` }),
|
|
352
|
+
/* @__PURE__ */ L(
|
|
353
|
+
"dialog",
|
|
354
|
+
{
|
|
355
|
+
ref: g,
|
|
356
|
+
style: {
|
|
357
|
+
border: "none",
|
|
358
|
+
padding: 0,
|
|
359
|
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
360
|
+
backdropFilter: "blur(2px)",
|
|
361
|
+
...o
|
|
362
|
+
},
|
|
363
|
+
className: l,
|
|
364
|
+
id: u,
|
|
365
|
+
children: /* @__PURE__ */ L(H, { scale: t, animationType: e, animationDuration: r, style: s, className: p, id: f, prefix: i.current, children: y.isValidElement(n) ? n : y.createElement(n) })
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
] }),
|
|
369
|
+
document.body
|
|
370
|
+
) : null;
|
|
371
|
+
}
|
|
372
|
+
function et({ isLoading: n = !1 }) {
|
|
373
|
+
const { startLoading: t, stopLoading: e } = A();
|
|
374
|
+
return k(() => (n ? t() : e(), () => {
|
|
375
|
+
n && e();
|
|
376
|
+
}), [n]), null;
|
|
377
|
+
}
|
|
378
|
+
export {
|
|
379
|
+
h as AnimationType,
|
|
380
|
+
G as EventEmitter,
|
|
381
|
+
et as Loading,
|
|
382
|
+
K as LoadingCircle,
|
|
383
|
+
q as LoadingPleaseWait,
|
|
384
|
+
tt as LoadingRenderer,
|
|
385
|
+
b as loadingEventTarget,
|
|
386
|
+
A as useLoading
|
|
387
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type EventMap = Record<string, any>;
|
|
2
|
+
export default class EventEmitter<T extends EventMap> extends EventTarget {
|
|
3
|
+
private controller;
|
|
4
|
+
on<K extends keyof T>(type: K, callback: (detail: T[K]) => void): this;
|
|
5
|
+
once<K extends keyof T>(type: K, callback: (detail: T[K]) => void): this;
|
|
6
|
+
emit<K extends keyof T>(type: K, detail: T[K]): boolean;
|
|
7
|
+
removeAllListeners(): void;
|
|
8
|
+
}
|
|
9
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rokku-x/react-hook-loading-spinner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight and flexible React loading state hook library with built-in spinner components, global state management, and zero dependencies (except React and Zustand).",
|
|
5
|
+
"main": "dist/index.cjs.js",
|
|
6
|
+
"module": "dist/index.esm.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "vite build",
|
|
14
|
+
"prepare": "npm run build",
|
|
15
|
+
"dev": "vite",
|
|
16
|
+
"preview": "vite preview",
|
|
17
|
+
"test": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/rokku-x/react-hook-loading-spinner.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/rokku-x/react-hook-loading-spinner/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/rokku-x/react-hook-loading-spinner#readme",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18.0.0",
|
|
33
|
+
"react-dom": "^18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@testing-library/jest-dom": "^6.0.0",
|
|
37
|
+
"@testing-library/react": "^14.0.0",
|
|
38
|
+
"@testing-library/user-event": "^14.5.2",
|
|
39
|
+
"@types/jest": "^30.0.0",
|
|
40
|
+
"@types/react": "^18.0.0",
|
|
41
|
+
"@types/react-dom": "^18.0.0",
|
|
42
|
+
"@vitejs/plugin-react": "^5.0.4",
|
|
43
|
+
"jsdom": "^27.4.0",
|
|
44
|
+
"typescript": "^5.0.0",
|
|
45
|
+
"vite": "^7.1.9",
|
|
46
|
+
"vite-plugin-dts": "^4.5.4",
|
|
47
|
+
"vitest": "^1.0.0"
|
|
48
|
+
},
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"exports": {
|
|
51
|
+
".": {
|
|
52
|
+
"types": "./dist/index.d.ts",
|
|
53
|
+
"import": "./dist/index.esm.js",
|
|
54
|
+
"require": "./dist/index.cjs.js"
|
|
55
|
+
},
|
|
56
|
+
"./package.json": "./package.json"
|
|
57
|
+
},
|
|
58
|
+
"typesVersions": {
|
|
59
|
+
"*": {
|
|
60
|
+
"*": [
|
|
61
|
+
"dist/*"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"keywords": [
|
|
66
|
+
"react",
|
|
67
|
+
"loading",
|
|
68
|
+
"spinner",
|
|
69
|
+
"hook",
|
|
70
|
+
"state",
|
|
71
|
+
"component",
|
|
72
|
+
"library",
|
|
73
|
+
"zustand",
|
|
74
|
+
"typescript"
|
|
75
|
+
],
|
|
76
|
+
"author": "rokku-x",
|
|
77
|
+
"license": "MIT",
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"zustand": "^5.0.10"
|
|
80
|
+
}
|
|
81
|
+
}
|