@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 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
+ [![npm version](https://img.shields.io/npm/v/@okyrychenko-dev/react-action-guard-router.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-router)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@okyrychenko-dev/react-action-guard-router.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-router)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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"]}