@okyrychenko-dev/react-action-guard-router 0.2.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/LICENSE +21 -0
- package/README.md +519 -0
- package/dist/index.cjs +91 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +158 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +84 -0
- package/dist/index.js.map +1 -0
- package/dist/nextjs/index.cjs +178 -0
- package/dist/nextjs/index.cjs.map +1 -0
- package/dist/nextjs/index.d.cts +120 -0
- package/dist/nextjs/index.d.ts +120 -0
- package/dist/nextjs/index.js +174 -0
- package/dist/nextjs/index.js.map +1 -0
- package/dist/react-router/index.cjs +193 -0
- package/dist/react-router/index.cjs.map +1 -0
- package/dist/react-router/index.d.cts +137 -0
- package/dist/react-router/index.d.ts +137 -0
- package/dist/react-router/index.js +189 -0
- package/dist/react-router/index.js.map +1 -0
- package/dist/tanstack-router/index.cjs +172 -0
- package/dist/tanstack-router/index.cjs.map +1 -0
- package/dist/tanstack-router/index.d.cts +75 -0
- package/dist/tanstack-router/index.d.ts +75 -0
- package/dist/tanstack-router/index.js +170 -0
- package/dist/tanstack-router/index.js.map +1 -0
- package/dist/types-C3CPuZip.d.cts +105 -0
- package/dist/types-C3CPuZip.d.ts +105 -0
- package/package.json +139 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Oleksii Kyrychenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
# @okyrychenko-dev/react-action-guard-router
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-router)
|
|
4
|
+
[](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-router)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
> Router integration for React Action Guard - navigation blocking for React Router, Remix, TanStack Router, and Next.js
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- ๐ฃ๏ธ **Multi-Router Support** - React Router v6+, Remix, TanStack Router, Next.js Pages Router, and best-effort App Router support
|
|
12
|
+
- ๐ฏ **Scope-Based Blocking** - Synchronize navigation blocking with UI blocking scopes
|
|
13
|
+
- ๐ฆ **Condition-Based Blocking** - Block based on boolean conditions or functions
|
|
14
|
+
- ๐ฌ **Custom Dialog Support** - `useDialogState` helper for async confirmation dialogs
|
|
15
|
+
- ๐ **Browser Protection** - Prevents tab close/refresh with `beforeunload` events
|
|
16
|
+
- ๐ **Sync & Async Handlers** - Support for both synchronous and Promise-based confirmations
|
|
17
|
+
- ๐ฆ **Tree-Shakeable** - Import only the router adapter you need
|
|
18
|
+
- ๐ **TypeScript-Friendly** - Strong editor support and typed router adapters
|
|
19
|
+
- ๐งน **Auto Cleanup** - Automatic listener cleanup on component unmount
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-action-guard
|
|
25
|
+
# or
|
|
26
|
+
yarn add @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-action-guard
|
|
27
|
+
# or
|
|
28
|
+
pnpm add @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-action-guard
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This package requires the following peer dependencies:
|
|
32
|
+
|
|
33
|
+
- [@okyrychenko-dev/react-action-guard](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard) ^1.0.1 - The core UI blocking library
|
|
34
|
+
- [React](https://react.dev/) ^18.0.0 || ^19.0.0
|
|
35
|
+
- One of:
|
|
36
|
+
- [react-router-dom](https://reactrouter.com/) ^6.0.0 - For React Router or Remix
|
|
37
|
+
- [@tanstack/react-router](https://tanstack.com/router) ^1.0.0 - For TanStack Router
|
|
38
|
+
- [next](https://nextjs.org/) ^13.4.0 - For Next.js Pages Router and best-effort App Router support
|
|
39
|
+
- [Zustand](https://zustand-demo.pmnd.rs/) - State management (peer dependency of react-action-guard)
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
### Basic Usage with react-action-guard
|
|
44
|
+
|
|
45
|
+
The most powerful pattern - synchronize navigation blocking with UI blocking:
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { useBlocker } from '@okyrychenko-dev/react-action-guard';
|
|
49
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
50
|
+
|
|
51
|
+
function PaymentForm() {
|
|
52
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
53
|
+
|
|
54
|
+
// Block UI during payment processing
|
|
55
|
+
useBlocker('payment-processing', {
|
|
56
|
+
scope: 'checkout',
|
|
57
|
+
reason: 'Processing payment...',
|
|
58
|
+
}, isProcessing);
|
|
59
|
+
|
|
60
|
+
// Block navigation when the same scope is active
|
|
61
|
+
useNavigationBlocker({
|
|
62
|
+
scope: 'checkout',
|
|
63
|
+
message: 'Payment is processing. Please wait.',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// When isProcessing = true, both UI and navigation are blocked
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Simple Condition-Based Blocking
|
|
71
|
+
|
|
72
|
+
For basic usage without react-action-guard scopes:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
76
|
+
|
|
77
|
+
function EditForm() {
|
|
78
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
79
|
+
|
|
80
|
+
useNavigationBlocker({
|
|
81
|
+
when: hasUnsavedChanges,
|
|
82
|
+
message: 'You have unsaved changes. Leave anyway?',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## API Reference
|
|
88
|
+
|
|
89
|
+
### Hooks
|
|
90
|
+
|
|
91
|
+
#### `useNavigationBlocker(options)`
|
|
92
|
+
|
|
93
|
+
Blocks navigation in router applications based on conditions or scope state.
|
|
94
|
+
|
|
95
|
+
**Parameters:**
|
|
96
|
+
|
|
97
|
+
- `options: UseNavigationBlockerOptions`
|
|
98
|
+
- `when?: boolean | (() => boolean)` - Condition to activate blocking
|
|
99
|
+
- `scope?: string | string[]` - Scope(s) from react-action-guard to monitor
|
|
100
|
+
- `message?: string` - Confirmation dialog message
|
|
101
|
+
- `onConfirm?: (message: string) => boolean | PromiseLike<boolean>` - Custom confirmation handler, called once per blocked navigation attempt
|
|
102
|
+
- `onBlock?: () => void` - Callback when navigation is blocked
|
|
103
|
+
- `onAllow?: () => void` - Callback when navigation is allowed
|
|
104
|
+
- `blockBrowserUnload?: boolean` - Block tab close/refresh (default: `true`)
|
|
105
|
+
|
|
106
|
+
**Returns:** `{ isBlocking: boolean; isIntercepting?: boolean }`
|
|
107
|
+
|
|
108
|
+
- `isBlocking` - Blocking condition is armed for this adapter
|
|
109
|
+
- `isIntercepting?: boolean` - Active interception state when the router can expose it
|
|
110
|
+
|
|
111
|
+
**Available in:**
|
|
112
|
+
- `@okyrychenko-dev/react-action-guard-router/react-router` - React Router v6+ & Remix
|
|
113
|
+
- `@okyrychenko-dev/react-action-guard-router/tanstack-router` - TanStack Router
|
|
114
|
+
- `@okyrychenko-dev/react-action-guard-router/nextjs` - Next.js Pages & App Router
|
|
115
|
+
|
|
116
|
+
**Examples:**
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
// Scope-based blocking
|
|
120
|
+
useNavigationBlocker({
|
|
121
|
+
scope: 'form',
|
|
122
|
+
message: 'You have unsaved changes',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Condition-based blocking
|
|
126
|
+
useNavigationBlocker({
|
|
127
|
+
when: hasChanges,
|
|
128
|
+
message: 'Discard changes?',
|
|
129
|
+
onBlock: () => console.log('Navigation blocked'),
|
|
130
|
+
onAllow: () => console.log('Navigation allowed'),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Multiple scopes
|
|
134
|
+
useNavigationBlocker({
|
|
135
|
+
scope: ['form', 'validation'],
|
|
136
|
+
message: 'Form is being validated',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Function condition
|
|
140
|
+
useNavigationBlocker({
|
|
141
|
+
when: () => formIsDirty() || hasUnsavedData(),
|
|
142
|
+
message: 'You have unsaved work',
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
#### `useDialogState<TMessage = string>()`
|
|
147
|
+
|
|
148
|
+
Helper hook for managing custom confirmation dialogs.
|
|
149
|
+
|
|
150
|
+
**Parameters:** None
|
|
151
|
+
|
|
152
|
+
**Returns:**
|
|
153
|
+
- `dialogState: DialogState<TMessage> | null` - Current dialog state
|
|
154
|
+
- `message: TMessage` - The message passed to confirm
|
|
155
|
+
- `isOpen: boolean` - Whether dialog is open
|
|
156
|
+
- `resolve: (value: boolean) => void` - Internal resolver
|
|
157
|
+
- `confirm: (message: TMessage) => Promise<boolean>` - Show dialog, returns Promise
|
|
158
|
+
- `onConfirm: () => void` - Resolve dialog with `true`
|
|
159
|
+
- `onCancel: () => void` - Resolve dialog with `false`
|
|
160
|
+
|
|
161
|
+
**Example:**
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
import { useNavigationBlocker, useDialogState } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
165
|
+
|
|
166
|
+
function MyComponent() {
|
|
167
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
168
|
+
const { dialogState, confirm, onConfirm, onCancel } = useDialogState();
|
|
169
|
+
|
|
170
|
+
useNavigationBlocker({
|
|
171
|
+
when: hasChanges,
|
|
172
|
+
onConfirm: confirm, // Returns Promise<boolean>
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
<form>...</form>
|
|
178
|
+
{dialogState && (
|
|
179
|
+
<CustomDialog
|
|
180
|
+
message={dialogState.message}
|
|
181
|
+
onConfirm={onConfirm}
|
|
182
|
+
onCancel={onCancel}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Custom message types:**
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
interface CustomMessage {
|
|
194
|
+
title: string;
|
|
195
|
+
body: string;
|
|
196
|
+
severity: 'warning' | 'error';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { dialogState, confirm } = useDialogState<CustomMessage>();
|
|
200
|
+
|
|
201
|
+
useNavigationBlocker({
|
|
202
|
+
when: isDirty,
|
|
203
|
+
onConfirm: () => confirm({
|
|
204
|
+
title: 'Unsaved Changes',
|
|
205
|
+
body: 'Your work will be lost',
|
|
206
|
+
severity: 'warning',
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
#### `usePrompt(message, when)` (React Router only)
|
|
212
|
+
|
|
213
|
+
Simple API similar to React Router v5's `usePrompt`.
|
|
214
|
+
|
|
215
|
+
**Parameters:**
|
|
216
|
+
- `message: string` - Confirmation message
|
|
217
|
+
- `when: boolean` - Condition to activate blocking
|
|
218
|
+
|
|
219
|
+
**Example:**
|
|
220
|
+
|
|
221
|
+
```tsx
|
|
222
|
+
import { usePrompt } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
223
|
+
|
|
224
|
+
function MyForm() {
|
|
225
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
226
|
+
usePrompt('You have unsaved changes', hasChanges);
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### `useBeforeUnload(when, message?)`
|
|
231
|
+
|
|
232
|
+
Standalone hook for blocking browser unload events (tab close, refresh).
|
|
233
|
+
|
|
234
|
+
**Parameters:**
|
|
235
|
+
- `when: boolean` - Condition to activate blocking
|
|
236
|
+
- `message?: string` - Optional custom message (default: "Are you sure you want to leave?")
|
|
237
|
+
|
|
238
|
+
**Example:**
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
import { useBeforeUnload } from '@okyrychenko-dev/react-action-guard-router';
|
|
242
|
+
|
|
243
|
+
function MyComponent() {
|
|
244
|
+
const [hasUnsavedWork, setHasUnsavedWork] = useState(false);
|
|
245
|
+
useBeforeUnload(hasUnsavedWork, 'You have unsaved work');
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
This utility works independently of any router and can be used in any React application.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Router-Specific Notes
|
|
254
|
+
|
|
255
|
+
### React Router (& Remix)
|
|
256
|
+
|
|
257
|
+
Full support with React Router v6's `useBlocker` hook.
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
261
|
+
|
|
262
|
+
function MyComponent() {
|
|
263
|
+
useNavigationBlocker({
|
|
264
|
+
when: isDirty,
|
|
265
|
+
message: 'Discard changes?',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
> **Note:** Remix uses React Router v6 internally, so the React Router adapter works seamlessly in Remix applications.
|
|
271
|
+
> `isIntercepting` is available here because React Router exposes blocker state directly.
|
|
272
|
+
|
|
273
|
+
### TanStack Router
|
|
274
|
+
|
|
275
|
+
Full support with TanStack Router's history blocking.
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
278
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/tanstack-router';
|
|
279
|
+
|
|
280
|
+
function MyComponent() {
|
|
281
|
+
useNavigationBlocker({
|
|
282
|
+
when: isDirty,
|
|
283
|
+
message: 'Discard changes?',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Async `onConfirm` is supported and evaluated once per blocked navigation attempt.
|
|
289
|
+
|
|
290
|
+
### Next.js Pages Router
|
|
291
|
+
|
|
292
|
+
Full support with Next.js router events.
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
// pages/edit.tsx
|
|
296
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/nextjs';
|
|
297
|
+
|
|
298
|
+
function EditPage() {
|
|
299
|
+
useNavigationBlocker({
|
|
300
|
+
when: isDirty,
|
|
301
|
+
message: 'Discard changes?',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
**Async `onConfirm` behavior:**
|
|
307
|
+
Pages Router does not allow pausing transitions. When you return a Promise from `onConfirm`, the hook cancels the current navigation and re-attempts it via `router.push(url)` if confirmed. This may not preserve original transition options (e.g., `shallow`, `scroll`, `locale`).
|
|
308
|
+
|
|
309
|
+
### Next.js App Router
|
|
310
|
+
|
|
311
|
+
Best-effort support only. Browser unload protection works, but App Router does not expose an official navigation-blocking API.
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
// app/edit/page.tsx
|
|
315
|
+
'use client';
|
|
316
|
+
|
|
317
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/nextjs';
|
|
318
|
+
|
|
319
|
+
function EditPage() {
|
|
320
|
+
useNavigationBlocker({
|
|
321
|
+
when: isDirty,
|
|
322
|
+
message: 'You have unsaved changes',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**App Router Limitations:**
|
|
328
|
+
- โ
Browser back/forward/close/refresh are blocked
|
|
329
|
+
- โ `<Link>` component navigation is NOT blocked
|
|
330
|
+
- โ `router.push()` is NOT blocked
|
|
331
|
+
|
|
332
|
+
For full navigation blocking support, use Pages Router.
|
|
333
|
+
|
|
334
|
+
## Adapter Capabilities
|
|
335
|
+
|
|
336
|
+
| Adapter | `isBlocking` meaning | `isIntercepting` | Async `onConfirm` | Caveats |
|
|
337
|
+
| --- | --- | --- | --- | --- |
|
|
338
|
+
| React Router | Blocking condition is armed | Yes | Yes | Best semantic fidelity |
|
|
339
|
+
| TanStack Router | Blocking condition is armed | No | Yes | Depends on history.block integration |
|
|
340
|
+
| Next.js Pages Router | Blocking condition is armed | No | Yes | Re-attempts confirmed navigation with `router.push(url)` |
|
|
341
|
+
| Next.js App Router | Blocking condition is armed | No | Best effort only | No official blocker API from Next.js |
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Use Cases
|
|
346
|
+
|
|
347
|
+
### Form Validation
|
|
348
|
+
|
|
349
|
+
```tsx
|
|
350
|
+
function SignupForm() {
|
|
351
|
+
const [formData, setFormData] = useState({});
|
|
352
|
+
const isDirty = Object.keys(formData).length > 0;
|
|
353
|
+
|
|
354
|
+
useNavigationBlocker({
|
|
355
|
+
when: isDirty,
|
|
356
|
+
message: 'Your signup progress will be lost. Continue?',
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Multi-Step Wizard
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
function MultiStepWizard() {
|
|
365
|
+
const [currentStep, setCurrentStep] = useState(1);
|
|
366
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
367
|
+
|
|
368
|
+
// Block UI during submission
|
|
369
|
+
useBlocker('wizard-submit', {
|
|
370
|
+
scope: 'wizard',
|
|
371
|
+
reason: 'Submitting form...',
|
|
372
|
+
}, isSubmitting);
|
|
373
|
+
|
|
374
|
+
// Block navigation during submission OR if wizard incomplete
|
|
375
|
+
useNavigationBlocker({
|
|
376
|
+
scope: 'wizard',
|
|
377
|
+
when: currentStep < 5,
|
|
378
|
+
message: `You're on step ${currentStep}/5. Exit wizard?`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### E-Commerce Checkout
|
|
384
|
+
|
|
385
|
+
```tsx
|
|
386
|
+
import { useBlockingMutation } from '@okyrychenko-dev/react-action-guard-tanstack';
|
|
387
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
388
|
+
|
|
389
|
+
function CheckoutPage() {
|
|
390
|
+
const paymentMutation = useBlockingMutation({
|
|
391
|
+
mutationFn: processPayment,
|
|
392
|
+
blockingConfig: { scope: 'checkout' },
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
useNavigationBlocker({
|
|
396
|
+
scope: 'checkout',
|
|
397
|
+
message: 'Payment is processing. Leaving will cancel the transaction.',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### File Upload with Progress
|
|
403
|
+
|
|
404
|
+
```tsx
|
|
405
|
+
function FileUploader() {
|
|
406
|
+
const [uploadProgress, setUploadProgress] = useState(0);
|
|
407
|
+
const isUploading = uploadProgress > 0 && uploadProgress < 100;
|
|
408
|
+
|
|
409
|
+
useNavigationBlocker({
|
|
410
|
+
when: isUploading,
|
|
411
|
+
message: `Upload ${uploadProgress}% complete. Cancel upload?`,
|
|
412
|
+
onBlock: () => pauseUpload(),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## TypeScript
|
|
420
|
+
|
|
421
|
+
TypeScript-friendly API surface:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
import type {
|
|
425
|
+
// Core types
|
|
426
|
+
UseNavigationBlockerOptions,
|
|
427
|
+
NavigationBlockerReturn,
|
|
428
|
+
DialogState,
|
|
429
|
+
ConfirmationResult,
|
|
430
|
+
ConfirmationCallbacks,
|
|
431
|
+
} from '@okyrychenko-dev/react-action-guard-router';
|
|
432
|
+
|
|
433
|
+
// Router-specific types
|
|
434
|
+
import type { UseNavigationBlockerOptions } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Typed scopes with `react-action-guard`:**
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
import { createTypedHooks } from '@okyrychenko-dev/react-action-guard';
|
|
441
|
+
|
|
442
|
+
type AppScopes = 'form' | 'checkout' | 'navigation';
|
|
443
|
+
const { useBlocker } = createTypedHooks<AppScopes>();
|
|
444
|
+
|
|
445
|
+
function MyComponent() {
|
|
446
|
+
useBlocker('id', { scope: 'form' }); // โ
Typed in the core package
|
|
447
|
+
|
|
448
|
+
useNavigationBlocker({
|
|
449
|
+
scope: 'form', // โ
Reuses the same scope values cleanly
|
|
450
|
+
message: 'Leave form?',
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Bundle Size
|
|
458
|
+
|
|
459
|
+
Tree-shakeable by router adapter. Import only what you need:
|
|
460
|
+
|
|
461
|
+
```tsx
|
|
462
|
+
// Only React Router code is bundled
|
|
463
|
+
import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router/react-router';
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Approximate sizes (minified):**
|
|
467
|
+
- Core utilities: ~2 KB
|
|
468
|
+
- React Router adapter: ~2.5 KB
|
|
469
|
+
- TanStack Router adapter: ~2.5 KB
|
|
470
|
+
- Next.js adapter: ~3 KB
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## Development
|
|
475
|
+
|
|
476
|
+
```bash
|
|
477
|
+
# Install dependencies
|
|
478
|
+
npm install
|
|
479
|
+
|
|
480
|
+
# Run tests
|
|
481
|
+
npm run test
|
|
482
|
+
|
|
483
|
+
# Build the package
|
|
484
|
+
npm run build
|
|
485
|
+
|
|
486
|
+
# Type checking
|
|
487
|
+
npm run typecheck
|
|
488
|
+
|
|
489
|
+
# Lint
|
|
490
|
+
npm run lint
|
|
491
|
+
|
|
492
|
+
# Fix lint errors
|
|
493
|
+
npm run lint:fix
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## Related Packages
|
|
499
|
+
|
|
500
|
+
- [@okyrychenko-dev/react-action-guard](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard) - Core UI blocking library
|
|
501
|
+
- [@okyrychenko-dev/react-action-guard-tanstack](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-tanstack) - TanStack Query integration
|
|
502
|
+
- [@okyrychenko-dev/react-action-guard-devtools](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-devtools) - DevTools extension
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Contributing
|
|
507
|
+
|
|
508
|
+
Contributions are welcome! Please ensure:
|
|
509
|
+
|
|
510
|
+
1. Tests pass (`npm run test:run`)
|
|
511
|
+
2. Code is properly typed (`npm run typecheck`)
|
|
512
|
+
3. Linting passes (`npm run lint`)
|
|
513
|
+
4. Code is formatted (`npm run format`)
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## License
|
|
518
|
+
|
|
519
|
+
MIT ยฉ [Oleksii Kyrychenko](https://github.com/okyrychenko-dev)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/core/useBeforeUnload.ts
|
|
6
|
+
|
|
7
|
+
// src/core/utils.ts
|
|
8
|
+
function resolveCondition(condition) {
|
|
9
|
+
return typeof condition === "function" ? condition() : condition;
|
|
10
|
+
}
|
|
11
|
+
function createBlockerId(prefix, scope) {
|
|
12
|
+
const scopePart = scope ? `-${Array.isArray(scope) ? scope.join("-") : scope}` : "";
|
|
13
|
+
const timestamp = String(Date.now());
|
|
14
|
+
return `${prefix}${scopePart}-${timestamp}`;
|
|
15
|
+
}
|
|
16
|
+
function isDefined(value) {
|
|
17
|
+
return value !== void 0 && value !== null;
|
|
18
|
+
}
|
|
19
|
+
function normalizeScope(scope) {
|
|
20
|
+
if (!scope) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return Array.isArray(scope) ? scope : [scope];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/core/useBeforeUnload.ts
|
|
27
|
+
var DEFAULT_UNLOAD_MESSAGE = "Changes you made may not be saved.";
|
|
28
|
+
function useBeforeUnload(when, message = DEFAULT_UNLOAD_MESSAGE) {
|
|
29
|
+
react.useEffect(() => {
|
|
30
|
+
const shouldBlock = resolveCondition(when);
|
|
31
|
+
if (!shouldBlock) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const handleBeforeUnload = (event) => {
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
if ("returnValue" in event) {
|
|
37
|
+
event.returnValue = message;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
41
|
+
return () => {
|
|
42
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
43
|
+
};
|
|
44
|
+
}, [when, message]);
|
|
45
|
+
}
|
|
46
|
+
function useDialogState() {
|
|
47
|
+
const [dialogState, setDialogState] = react.useState(null);
|
|
48
|
+
const resolveRef = react.useRef(null);
|
|
49
|
+
const confirm = react.useCallback((message) => {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (resolveRef.current) {
|
|
52
|
+
resolveRef.current(false);
|
|
53
|
+
}
|
|
54
|
+
resolveRef.current = resolve;
|
|
55
|
+
setDialogState({
|
|
56
|
+
isOpen: true,
|
|
57
|
+
message,
|
|
58
|
+
resolve
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
const closeDialog = react.useCallback((confirmed) => {
|
|
63
|
+
const resolve = resolveRef.current;
|
|
64
|
+
if (resolve) {
|
|
65
|
+
resolve(confirmed);
|
|
66
|
+
}
|
|
67
|
+
resolveRef.current = null;
|
|
68
|
+
setDialogState(null);
|
|
69
|
+
}, []);
|
|
70
|
+
const onConfirm = react.useCallback(() => {
|
|
71
|
+
closeDialog(true);
|
|
72
|
+
}, [closeDialog]);
|
|
73
|
+
const onCancel = react.useCallback(() => {
|
|
74
|
+
closeDialog(false);
|
|
75
|
+
}, [closeDialog]);
|
|
76
|
+
return {
|
|
77
|
+
dialogState,
|
|
78
|
+
confirm,
|
|
79
|
+
onConfirm,
|
|
80
|
+
onCancel
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
exports.createBlockerId = createBlockerId;
|
|
85
|
+
exports.isDefined = isDefined;
|
|
86
|
+
exports.normalizeScope = normalizeScope;
|
|
87
|
+
exports.resolveCondition = resolveCondition;
|
|
88
|
+
exports.useBeforeUnload = useBeforeUnload;
|
|
89
|
+
exports.useDialogState = useDialogState;
|
|
90
|
+
//# sourceMappingURL=index.cjs.map
|
|
91
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/utils.ts","../src/core/useBeforeUnload.ts","../src/core/useDialogState.ts"],"names":["useEffect","useState","useRef","useCallback"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Resolves a condition value (boolean or function returning boolean)\n *\n * @param condition - Boolean value or function\n * @returns Resolved boolean value\n */\nexport function resolveCondition(condition: boolean | (() => boolean)): boolean {\n return typeof condition === \"function\" ? condition() : condition;\n}\n\ninterface Thenable {\n then: (...args: Array<unknown>) => unknown;\n}\n\nconst hasThen = (value: unknown): value is Thenable =>\n typeof value === \"object\" && value !== null && \"then\" in value;\n\n/**\n * Checks if a value behaves like a Promise (thenable)\n */\nexport function isThenable<T>(value: unknown): value is PromiseLike<T> {\n if (!hasThen(value)) {\n return false;\n }\n return typeof value.then === \"function\";\n}\n\nexport interface ConfirmResultSync {\n kind: \"sync\";\n confirmed: boolean;\n}\n\nexport interface ConfirmResultAsync {\n kind: \"async\";\n promise: Promise<boolean>;\n}\n\nexport type ConfirmResult = ConfirmResultSync | ConfirmResultAsync;\n\n/**\n * Normalizes custom confirm result into sync or async branch.\n */\nexport function resolveConfirmResult(\n message: string,\n onConfirm: ((message: string) => boolean | PromiseLike<boolean>) | undefined,\n fallbackConfirm: (message: string) => boolean\n): ConfirmResult {\n if (!onConfirm) {\n return { kind: \"sync\", confirmed: fallbackConfirm(message) };\n }\n\n const result = onConfirm(message);\n if (isThenable<boolean>(result)) {\n return { kind: \"async\", promise: Promise.resolve(result) };\n }\n\n return { kind: \"sync\", confirmed: result };\n}\n\n/**\n * Generates a unique blocker ID based on scope and timestamp\n *\n * @param prefix - Prefix for the ID\n * @param scope - Optional scope\n * @returns Unique blocker ID\n */\nexport function createBlockerId(prefix: string, scope?: string | Array<string>): string {\n const scopePart = scope ? `-${Array.isArray(scope) ? scope.join(\"-\") : scope}` : \"\";\n const timestamp = String(Date.now());\n return `${prefix}${scopePart}-${timestamp}`;\n}\n\n/**\n * Checks if a value is defined and not empty\n *\n * @param value - Value to check\n * @returns True if value is defined and not empty\n */\nexport function isDefined<T>(value: T | undefined | null): value is T {\n return value !== undefined && value !== null;\n}\n\n/**\n * Normalizes scope to an array\n *\n * @param scope - String or array of strings\n * @returns Array of scope strings\n */\nexport function normalizeScope(scope: string | Array<string> | undefined): Array<string> {\n if (!scope) {\n return [];\n }\n return Array.isArray(scope) ? scope : [scope];\n}\n","import { useEffect } from \"react\";\nimport { resolveCondition } from \"./utils\";\n\n/**\n * Default message shown when preventing browser unload\n */\nexport const DEFAULT_UNLOAD_MESSAGE = \"Changes you made may not be saved.\";\n\n/**\n * Blocks browser tab close/refresh when condition is met.\n *\n * This hook adds a `beforeunload` event listener to warn users\n * before they close or refresh the browser tab. Modern browsers\n * may ignore custom messages and show their own generic warning.\n *\n * @param when - Boolean or function returning boolean to determine if blocking should be active\n * @param message - Optional custom message (may be ignored by browsers)\n *\n * @example\n * ```tsx\n * function MyForm() {\n * const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);\n *\n * useBeforeUnload(hasUnsavedChanges, 'You have unsaved changes');\n *\n * return <form>...</form>;\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With function condition\n * function EditPage() {\n * const isFormDirty = useIsBlocked('form');\n *\n * useBeforeUnload(() => isFormDirty);\n *\n * return <div>...</div>;\n * }\n * ```\n */\nexport function useBeforeUnload(\n when: boolean | (() => boolean),\n message = DEFAULT_UNLOAD_MESSAGE\n): void {\n useEffect(() => {\n const shouldBlock = resolveCondition(when);\n\n if (!shouldBlock) {\n return;\n }\n\n const handleBeforeUnload = (event: BeforeUnloadEvent): void => {\n // Prevent default to trigger the browser's confirmation dialog\n event.preventDefault();\n\n // Fallback for legacy browsers/WebViews that still rely on returnValue.\n if (\"returnValue\" in event) {\n // eslint-disable-next-line @typescript-eslint/no-deprecated\n event.returnValue = message;\n }\n };\n\n window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n return () => {\n window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n };\n }, [when, message]);\n}\n","import { useCallback, useRef, useState } from \"react\";\n\n/**\n * State object for a confirmation dialog\n */\nexport interface DialogState<T = string> {\n /** Whether the dialog is currently open */\n isOpen: boolean;\n\n /** The data to display in the dialog (typically a message) */\n message: T;\n\n /** Resolver function to confirm/cancel navigation */\n resolve: (confirmed: boolean) => void;\n}\n\n/**\n * Return type for useDialogState hook\n */\nexport interface UseDialogStateReturn<T = string> {\n /** Current dialog state (null when closed) */\n dialogState: DialogState<T> | null;\n\n /**\n * Function to use as onConfirm callback.\n * Returns a Promise that resolves when user confirms/cancels.\n */\n confirm: (message: T) => Promise<boolean>;\n\n /** Close dialog with confirmation (allows navigation) */\n onConfirm: () => void;\n\n /** Close dialog with cancellation (blocks navigation) */\n onCancel: () => void;\n}\n\n/**\n * Hook to manage custom confirmation dialog state.\n *\n * Simplifies integration with custom dialog components when using\n * navigation blocking with `onConfirm` callback.\n *\n * @template T - Type of message/data to pass to dialog (defaults to string)\n * @returns Object with dialog state and control functions\n *\n * @example\n * ```tsx\n * function MyForm() {\n * const [isDirty, setIsDirty] = useState(false);\n * const { dialogState, confirm, onConfirm, onCancel } = useDialogState();\n *\n * useNavigationBlocker({\n * when: isDirty,\n * message: 'Unsaved changes',\n * onConfirm: confirm,\n * });\n *\n * return (\n * <>\n * <form>...</form>\n * {dialogState && (\n * <ConfirmDialog\n * message={dialogState.message}\n * onConfirm={onConfirm}\n * onCancel={onCancel}\n * />\n * )}\n * </>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With Material-UI\n * import { Dialog, DialogTitle, DialogActions, Button } from '@mui/material';\n *\n * function EditPage() {\n * const { dialogState, confirm, onConfirm, onCancel } = useDialogState();\n *\n * useNavigationBlocker({\n * when: hasChanges,\n * onConfirm: confirm,\n * });\n *\n * return (\n * <Dialog open={dialogState?.isOpen ?? false}>\n * <DialogTitle>{dialogState?.message}</DialogTitle>\n * <DialogActions>\n * <Button onClick={onCancel}>Stay</Button>\n * <Button onClick={onConfirm}>Leave</Button>\n * </DialogActions>\n * </Dialog>\n * );\n * }\n * ```\n */\nexport function useDialogState<T = string>(): UseDialogStateReturn<T> {\n const [dialogState, setDialogState] = useState<DialogState<T> | null>(null);\n const resolveRef = useRef<DialogState<T>[\"resolve\"] | null>(null);\n\n const confirm = useCallback((message: T): Promise<boolean> => {\n return new Promise<boolean>((resolve) => {\n if (resolveRef.current) {\n resolveRef.current(false);\n }\n resolveRef.current = resolve;\n setDialogState({\n isOpen: true,\n message,\n resolve,\n });\n });\n }, []);\n\n const closeDialog = useCallback((confirmed: boolean) => {\n const resolve = resolveRef.current;\n if (resolve) {\n resolve(confirmed);\n }\n resolveRef.current = null;\n setDialogState(null);\n }, []);\n\n const onConfirm = useCallback(() => {\n closeDialog(true);\n }, [closeDialog]);\n\n const onCancel = useCallback(() => {\n closeDialog(false);\n }, [closeDialog]);\n\n return {\n dialogState,\n confirm,\n onConfirm,\n onCancel,\n };\n}\n"]}
|