@okyrychenko-dev/react-action-guard-router 0.2.3 → 1.0.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/CHANGELOG.md +228 -0
- package/README.md +187 -110
- package/dist/index.cjs +30 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -101
- package/dist/index.d.ts +9 -101
- package/dist/index.js +28 -17
- package/dist/index.js.map +1 -1
- package/dist/nextjs/index.cjs +89 -53
- package/dist/nextjs/index.cjs.map +1 -1
- package/dist/nextjs/index.d.cts +1 -100
- package/dist/nextjs/index.d.ts +1 -100
- package/dist/nextjs/index.js +83 -47
- package/dist/nextjs/index.js.map +1 -1
- package/dist/react-router/index.cjs +69 -46
- package/dist/react-router/index.cjs.map +1 -1
- package/dist/react-router/index.d.cts +1 -87
- package/dist/react-router/index.d.ts +1 -87
- package/dist/react-router/index.js +69 -46
- package/dist/react-router/index.js.map +1 -1
- package/dist/tanstack-router/index.cjs +57 -105
- package/dist/tanstack-router/index.cjs.map +1 -1
- package/dist/tanstack-router/index.d.cts +2 -63
- package/dist/tanstack-router/index.d.ts +2 -63
- package/dist/tanstack-router/index.js +58 -106
- package/dist/tanstack-router/index.js.map +1 -1
- package/dist/{types-C3CPuZip.d.cts → types-xxRwsJ1N.d.cts} +0 -35
- package/dist/{types-C3CPuZip.d.ts → types-xxRwsJ1N.d.ts} +0 -35
- package/package.json +80 -66
package/dist/index.cjs
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var react = require('react');
|
|
4
|
+
var typeUtils = require('@okyrychenko-dev/type-utils');
|
|
4
5
|
|
|
5
6
|
// src/core/useBeforeUnload.ts
|
|
6
|
-
|
|
7
|
-
// src/core/utils.ts
|
|
8
7
|
function resolveCondition(condition) {
|
|
9
|
-
return
|
|
8
|
+
return typeUtils.isFunction(condition) ? condition() : condition;
|
|
10
9
|
}
|
|
11
10
|
function createBlockerId(prefix, scope) {
|
|
12
|
-
const scopePart = scope ? `-${
|
|
11
|
+
const scopePart = scope ? `-${typeUtils.isArray(scope) ? scope.join("-") : scope}` : "";
|
|
13
12
|
const timestamp = String(Date.now());
|
|
14
13
|
return `${prefix}${scopePart}-${timestamp}`;
|
|
15
14
|
}
|
|
16
|
-
function isDefined(value) {
|
|
17
|
-
return value !== void 0 && value !== null;
|
|
18
|
-
}
|
|
19
15
|
function normalizeScope(scope) {
|
|
20
16
|
if (!scope) {
|
|
21
17
|
return [];
|
|
22
18
|
}
|
|
23
|
-
return
|
|
19
|
+
return typeUtils.isArray(scope) ? scope : [scope];
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
// src/core/useBeforeUnload.ts
|
|
@@ -46,26 +42,40 @@ function useBeforeUnload(when, message = DEFAULT_UNLOAD_MESSAGE) {
|
|
|
46
42
|
function useDialogState() {
|
|
47
43
|
const [dialogState, setDialogState] = react.useState(null);
|
|
48
44
|
const resolveRef = react.useRef(null);
|
|
45
|
+
const mountedRef = react.useRef(true);
|
|
46
|
+
react.useEffect(() => {
|
|
47
|
+
mountedRef.current = true;
|
|
48
|
+
return () => {
|
|
49
|
+
mountedRef.current = false;
|
|
50
|
+
resolveRef.current?.(false);
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
49
53
|
const confirm = react.useCallback((message) => {
|
|
54
|
+
if (!mountedRef.current) {
|
|
55
|
+
return Promise.resolve(false);
|
|
56
|
+
}
|
|
50
57
|
return new Promise((resolve) => {
|
|
51
58
|
if (resolveRef.current) {
|
|
52
59
|
resolveRef.current(false);
|
|
53
60
|
}
|
|
54
|
-
|
|
61
|
+
const settle = (confirmed) => {
|
|
62
|
+
if (resolveRef.current !== settle) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
resolveRef.current = null;
|
|
66
|
+
resolve(confirmed);
|
|
67
|
+
setDialogState(null);
|
|
68
|
+
};
|
|
69
|
+
resolveRef.current = settle;
|
|
55
70
|
setDialogState({
|
|
56
71
|
isOpen: true,
|
|
57
72
|
message,
|
|
58
|
-
resolve
|
|
73
|
+
resolve: settle
|
|
59
74
|
});
|
|
60
75
|
});
|
|
61
76
|
}, []);
|
|
62
77
|
const closeDialog = react.useCallback((confirmed) => {
|
|
63
|
-
|
|
64
|
-
if (resolve) {
|
|
65
|
-
resolve(confirmed);
|
|
66
|
-
}
|
|
67
|
-
resolveRef.current = null;
|
|
68
|
-
setDialogState(null);
|
|
78
|
+
resolveRef.current?.(confirmed);
|
|
69
79
|
}, []);
|
|
70
80
|
const onConfirm = react.useCallback(() => {
|
|
71
81
|
closeDialog(true);
|
|
@@ -81,8 +91,11 @@ function useDialogState() {
|
|
|
81
91
|
};
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
Object.defineProperty(exports, "isDefined", {
|
|
95
|
+
enumerable: true,
|
|
96
|
+
get: function () { return typeUtils.isDefined; }
|
|
97
|
+
});
|
|
84
98
|
exports.createBlockerId = createBlockerId;
|
|
85
|
-
exports.isDefined = isDefined;
|
|
86
99
|
exports.normalizeScope = normalizeScope;
|
|
87
100
|
exports.resolveCondition = resolveCondition;
|
|
88
101
|
exports.useBeforeUnload = useBeforeUnload;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"sources":["../src/core/utils.ts","../src/core/useBeforeUnload.ts","../src/core/useDialogState.ts"],"names":["isFunction","isArray","useEffect","useState","useRef","useCallback"],"mappings":"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","file":"index.cjs","sourcesContent":["import { isArray, isFunction, isObject } from \"@okyrychenko-dev/type-utils\";\nimport type { Optional } from \"@okyrychenko-dev/type-utils\";\n\n/**\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 isFunction(condition) ? condition() : condition;\n}\n\ninterface Thenable {\n then: (...args: Array<unknown>) => unknown;\n}\n\nconst hasThen = (value: unknown): value is Thenable => isObject(value) && \"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\n return isFunction(value.then);\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: Optional<(message: string) => boolean | PromiseLike<boolean>>,\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\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 ? `-${isArray(scope) ? scope.join(\"-\") : scope}` : \"\";\n const timestamp = String(Date.now());\n\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 { isDefined } from \"@okyrychenko-dev/type-utils\";\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: Optional<string | Array<string>>): Array<string> {\n if (!scope) {\n return [];\n }\n\n return isArray(scope) ? scope : [scope];\n}\n\n/** Identifies protection independently of scope order and duplicate entries. */\nexport function createScopeKey(scope: Optional<string | Array<string>>): string {\n return JSON.stringify([...new Set(normalizeScope(scope))].sort());\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 */\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, useEffect, useRef, useState } from \"react\";\nimport type { Nullable } from \"@okyrychenko-dev/type-utils\";\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 /** Settle and close this dialog; repeated or stale calls have no effect */\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: Nullable<DialogState<T>>;\n\n /**\n * Function to use as onConfirm callback.\n * Resolves true on confirmation, false on cancellation, replacement or effect teardown.\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 */\nexport function useDialogState<T = string>(): UseDialogStateReturn<T> {\n const [dialogState, setDialogState] = useState<Nullable<DialogState<T>>>(null);\n const resolveRef = useRef<Nullable<DialogState<T>[\"resolve\"]>>(null);\n const mountedRef = useRef(true);\n\n useEffect(() => {\n mountedRef.current = true;\n\n return () => {\n mountedRef.current = false;\n resolveRef.current?.(false);\n };\n }, []);\n\n const confirm = useCallback((message: T): Promise<boolean> => {\n if (!mountedRef.current) {\n return Promise.resolve(false);\n }\n\n return new Promise<boolean>((resolve) => {\n if (resolveRef.current) {\n resolveRef.current(false);\n }\n\n const settle = (confirmed: boolean): void => {\n if (resolveRef.current !== settle) {\n return;\n }\n\n resolveRef.current = null;\n resolve(confirmed);\n\n setDialogState(null);\n };\n\n resolveRef.current = settle;\n\n setDialogState({\n isOpen: true,\n message,\n resolve: settle,\n });\n });\n }, []);\n\n const closeDialog = useCallback((confirmed: boolean) => {\n resolveRef.current?.(confirmed);\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"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,37 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { Nullable, Optional } from '@okyrychenko-dev/type-utils';
|
|
2
|
+
export { isDefined } from '@okyrychenko-dev/type-utils';
|
|
3
|
+
export { B as BaseNavigationBlockerOptions, a as BeforeUnloadOptions, N as NavigationBlockerReturn } from './types-xxRwsJ1N.cjs';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Blocks browser tab close/refresh when condition is met.
|
|
5
|
-
*
|
|
6
|
-
* This hook adds a `beforeunload` event listener to warn users
|
|
7
|
-
* before they close or refresh the browser tab. Modern browsers
|
|
8
|
-
* may ignore custom messages and show their own generic warning.
|
|
9
|
-
*
|
|
10
|
-
* @param when - Boolean or function returning boolean to determine if blocking should be active
|
|
11
|
-
* @param message - Optional custom message (may be ignored by browsers)
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* ```tsx
|
|
15
|
-
* function MyForm() {
|
|
16
|
-
* const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
17
|
-
*
|
|
18
|
-
* useBeforeUnload(hasUnsavedChanges, 'You have unsaved changes');
|
|
19
|
-
*
|
|
20
|
-
* return <form>...</form>;
|
|
21
|
-
* }
|
|
22
|
-
* ```
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* ```tsx
|
|
26
|
-
* // With function condition
|
|
27
|
-
* function EditPage() {
|
|
28
|
-
* const isFormDirty = useIsBlocked('form');
|
|
29
|
-
*
|
|
30
|
-
* useBeforeUnload(() => isFormDirty);
|
|
31
|
-
*
|
|
32
|
-
* return <div>...</div>;
|
|
33
|
-
* }
|
|
34
|
-
* ```
|
|
35
7
|
*/
|
|
36
8
|
declare function useBeforeUnload(when: boolean | (() => boolean), message?: string): void;
|
|
37
9
|
|
|
@@ -43,7 +15,7 @@ interface DialogState<T = string> {
|
|
|
43
15
|
isOpen: boolean;
|
|
44
16
|
/** The data to display in the dialog (typically a message) */
|
|
45
17
|
message: T;
|
|
46
|
-
/**
|
|
18
|
+
/** Settle and close this dialog; repeated or stale calls have no effect */
|
|
47
19
|
resolve: (confirmed: boolean) => void;
|
|
48
20
|
}
|
|
49
21
|
/**
|
|
@@ -51,10 +23,10 @@ interface DialogState<T = string> {
|
|
|
51
23
|
*/
|
|
52
24
|
interface UseDialogStateReturn<T = string> {
|
|
53
25
|
/** Current dialog state (null when closed) */
|
|
54
|
-
dialogState: DialogState<T
|
|
26
|
+
dialogState: Nullable<DialogState<T>>;
|
|
55
27
|
/**
|
|
56
28
|
* Function to use as onConfirm callback.
|
|
57
|
-
*
|
|
29
|
+
* Resolves true on confirmation, false on cancellation, replacement or effect teardown.
|
|
58
30
|
*/
|
|
59
31
|
confirm: (message: T) => Promise<boolean>;
|
|
60
32
|
/** Close dialog with confirmation (allows navigation) */
|
|
@@ -64,64 +36,6 @@ interface UseDialogStateReturn<T = string> {
|
|
|
64
36
|
}
|
|
65
37
|
/**
|
|
66
38
|
* Hook to manage custom confirmation dialog state.
|
|
67
|
-
*
|
|
68
|
-
* Simplifies integration with custom dialog components when using
|
|
69
|
-
* navigation blocking with `onConfirm` callback.
|
|
70
|
-
*
|
|
71
|
-
* @template T - Type of message/data to pass to dialog (defaults to string)
|
|
72
|
-
* @returns Object with dialog state and control functions
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```tsx
|
|
76
|
-
* function MyForm() {
|
|
77
|
-
* const [isDirty, setIsDirty] = useState(false);
|
|
78
|
-
* const { dialogState, confirm, onConfirm, onCancel } = useDialogState();
|
|
79
|
-
*
|
|
80
|
-
* useNavigationBlocker({
|
|
81
|
-
* when: isDirty,
|
|
82
|
-
* message: 'Unsaved changes',
|
|
83
|
-
* onConfirm: confirm,
|
|
84
|
-
* });
|
|
85
|
-
*
|
|
86
|
-
* return (
|
|
87
|
-
* <>
|
|
88
|
-
* <form>...</form>
|
|
89
|
-
* {dialogState && (
|
|
90
|
-
* <ConfirmDialog
|
|
91
|
-
* message={dialogState.message}
|
|
92
|
-
* onConfirm={onConfirm}
|
|
93
|
-
* onCancel={onCancel}
|
|
94
|
-
* />
|
|
95
|
-
* )}
|
|
96
|
-
* </>
|
|
97
|
-
* );
|
|
98
|
-
* }
|
|
99
|
-
* ```
|
|
100
|
-
*
|
|
101
|
-
* @example
|
|
102
|
-
* ```tsx
|
|
103
|
-
* // With Material-UI
|
|
104
|
-
* import { Dialog, DialogTitle, DialogActions, Button } from '@mui/material';
|
|
105
|
-
*
|
|
106
|
-
* function EditPage() {
|
|
107
|
-
* const { dialogState, confirm, onConfirm, onCancel } = useDialogState();
|
|
108
|
-
*
|
|
109
|
-
* useNavigationBlocker({
|
|
110
|
-
* when: hasChanges,
|
|
111
|
-
* onConfirm: confirm,
|
|
112
|
-
* });
|
|
113
|
-
*
|
|
114
|
-
* return (
|
|
115
|
-
* <Dialog open={dialogState?.isOpen ?? false}>
|
|
116
|
-
* <DialogTitle>{dialogState?.message}</DialogTitle>
|
|
117
|
-
* <DialogActions>
|
|
118
|
-
* <Button onClick={onCancel}>Stay</Button>
|
|
119
|
-
* <Button onClick={onConfirm}>Leave</Button>
|
|
120
|
-
* </DialogActions>
|
|
121
|
-
* </Dialog>
|
|
122
|
-
* );
|
|
123
|
-
* }
|
|
124
|
-
* ```
|
|
125
39
|
*/
|
|
126
40
|
declare function useDialogState<T = string>(): UseDialogStateReturn<T>;
|
|
127
41
|
|
|
@@ -140,19 +54,13 @@ declare function resolveCondition(condition: boolean | (() => boolean)): boolean
|
|
|
140
54
|
* @returns Unique blocker ID
|
|
141
55
|
*/
|
|
142
56
|
declare function createBlockerId(prefix: string, scope?: string | Array<string>): string;
|
|
143
|
-
|
|
144
|
-
* Checks if a value is defined and not empty
|
|
145
|
-
*
|
|
146
|
-
* @param value - Value to check
|
|
147
|
-
* @returns True if value is defined and not empty
|
|
148
|
-
*/
|
|
149
|
-
declare function isDefined<T>(value: T | undefined | null): value is T;
|
|
57
|
+
|
|
150
58
|
/**
|
|
151
59
|
* Normalizes scope to an array
|
|
152
60
|
*
|
|
153
61
|
* @param scope - String or array of strings
|
|
154
62
|
* @returns Array of scope strings
|
|
155
63
|
*/
|
|
156
|
-
declare function normalizeScope(scope: string | Array<string
|
|
64
|
+
declare function normalizeScope(scope: Optional<string | Array<string>>): Array<string>;
|
|
157
65
|
|
|
158
|
-
export { type DialogState, type UseDialogStateReturn, createBlockerId,
|
|
66
|
+
export { type DialogState, type UseDialogStateReturn, createBlockerId, normalizeScope, resolveCondition, useBeforeUnload, useDialogState };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,37 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import { Nullable, Optional } from '@okyrychenko-dev/type-utils';
|
|
2
|
+
export { isDefined } from '@okyrychenko-dev/type-utils';
|
|
3
|
+
export { B as BaseNavigationBlockerOptions, a as BeforeUnloadOptions, N as NavigationBlockerReturn } from './types-xxRwsJ1N.js';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Blocks browser tab close/refresh when condition is met.
|
|
5
|
-
*
|
|
6
|
-
* This hook adds a `beforeunload` event listener to warn users
|
|
7
|
-
* before they close or refresh the browser tab. Modern browsers
|
|
8
|
-
* may ignore custom messages and show their own generic warning.
|
|
9
|
-
*
|
|
10
|
-
* @param when - Boolean or function returning boolean to determine if blocking should be active
|
|
11
|
-
* @param message - Optional custom message (may be ignored by browsers)
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* ```tsx
|
|
15
|
-
* function MyForm() {
|
|
16
|
-
* const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
17
|
-
*
|
|
18
|
-
* useBeforeUnload(hasUnsavedChanges, 'You have unsaved changes');
|
|
19
|
-
*
|
|
20
|
-
* return <form>...</form>;
|
|
21
|
-
* }
|
|
22
|
-
* ```
|
|
23
|
-
*
|
|
24
|
-
* @example
|
|
25
|
-
* ```tsx
|
|
26
|
-
* // With function condition
|
|
27
|
-
* function EditPage() {
|
|
28
|
-
* const isFormDirty = useIsBlocked('form');
|
|
29
|
-
*
|
|
30
|
-
* useBeforeUnload(() => isFormDirty);
|
|
31
|
-
*
|
|
32
|
-
* return <div>...</div>;
|
|
33
|
-
* }
|
|
34
|
-
* ```
|
|
35
7
|
*/
|
|
36
8
|
declare function useBeforeUnload(when: boolean | (() => boolean), message?: string): void;
|
|
37
9
|
|
|
@@ -43,7 +15,7 @@ interface DialogState<T = string> {
|
|
|
43
15
|
isOpen: boolean;
|
|
44
16
|
/** The data to display in the dialog (typically a message) */
|
|
45
17
|
message: T;
|
|
46
|
-
/**
|
|
18
|
+
/** Settle and close this dialog; repeated or stale calls have no effect */
|
|
47
19
|
resolve: (confirmed: boolean) => void;
|
|
48
20
|
}
|
|
49
21
|
/**
|
|
@@ -51,10 +23,10 @@ interface DialogState<T = string> {
|
|
|
51
23
|
*/
|
|
52
24
|
interface UseDialogStateReturn<T = string> {
|
|
53
25
|
/** Current dialog state (null when closed) */
|
|
54
|
-
dialogState: DialogState<T
|
|
26
|
+
dialogState: Nullable<DialogState<T>>;
|
|
55
27
|
/**
|
|
56
28
|
* Function to use as onConfirm callback.
|
|
57
|
-
*
|
|
29
|
+
* Resolves true on confirmation, false on cancellation, replacement or effect teardown.
|
|
58
30
|
*/
|
|
59
31
|
confirm: (message: T) => Promise<boolean>;
|
|
60
32
|
/** Close dialog with confirmation (allows navigation) */
|
|
@@ -64,64 +36,6 @@ interface UseDialogStateReturn<T = string> {
|
|
|
64
36
|
}
|
|
65
37
|
/**
|
|
66
38
|
* Hook to manage custom confirmation dialog state.
|
|
67
|
-
*
|
|
68
|
-
* Simplifies integration with custom dialog components when using
|
|
69
|
-
* navigation blocking with `onConfirm` callback.
|
|
70
|
-
*
|
|
71
|
-
* @template T - Type of message/data to pass to dialog (defaults to string)
|
|
72
|
-
* @returns Object with dialog state and control functions
|
|
73
|
-
*
|
|
74
|
-
* @example
|
|
75
|
-
* ```tsx
|
|
76
|
-
* function MyForm() {
|
|
77
|
-
* const [isDirty, setIsDirty] = useState(false);
|
|
78
|
-
* const { dialogState, confirm, onConfirm, onCancel } = useDialogState();
|
|
79
|
-
*
|
|
80
|
-
* useNavigationBlocker({
|
|
81
|
-
* when: isDirty,
|
|
82
|
-
* message: 'Unsaved changes',
|
|
83
|
-
* onConfirm: confirm,
|
|
84
|
-
* });
|
|
85
|
-
*
|
|
86
|
-
* return (
|
|
87
|
-
* <>
|
|
88
|
-
* <form>...</form>
|
|
89
|
-
* {dialogState && (
|
|
90
|
-
* <ConfirmDialog
|
|
91
|
-
* message={dialogState.message}
|
|
92
|
-
* onConfirm={onConfirm}
|
|
93
|
-
* onCancel={onCancel}
|
|
94
|
-
* />
|
|
95
|
-
* )}
|
|
96
|
-
* </>
|
|
97
|
-
* );
|
|
98
|
-
* }
|
|
99
|
-
* ```
|
|
100
|
-
*
|
|
101
|
-
* @example
|
|
102
|
-
* ```tsx
|
|
103
|
-
* // With Material-UI
|
|
104
|
-
* import { Dialog, DialogTitle, DialogActions, Button } from '@mui/material';
|
|
105
|
-
*
|
|
106
|
-
* function EditPage() {
|
|
107
|
-
* const { dialogState, confirm, onConfirm, onCancel } = useDialogState();
|
|
108
|
-
*
|
|
109
|
-
* useNavigationBlocker({
|
|
110
|
-
* when: hasChanges,
|
|
111
|
-
* onConfirm: confirm,
|
|
112
|
-
* });
|
|
113
|
-
*
|
|
114
|
-
* return (
|
|
115
|
-
* <Dialog open={dialogState?.isOpen ?? false}>
|
|
116
|
-
* <DialogTitle>{dialogState?.message}</DialogTitle>
|
|
117
|
-
* <DialogActions>
|
|
118
|
-
* <Button onClick={onCancel}>Stay</Button>
|
|
119
|
-
* <Button onClick={onConfirm}>Leave</Button>
|
|
120
|
-
* </DialogActions>
|
|
121
|
-
* </Dialog>
|
|
122
|
-
* );
|
|
123
|
-
* }
|
|
124
|
-
* ```
|
|
125
39
|
*/
|
|
126
40
|
declare function useDialogState<T = string>(): UseDialogStateReturn<T>;
|
|
127
41
|
|
|
@@ -140,19 +54,13 @@ declare function resolveCondition(condition: boolean | (() => boolean)): boolean
|
|
|
140
54
|
* @returns Unique blocker ID
|
|
141
55
|
*/
|
|
142
56
|
declare function createBlockerId(prefix: string, scope?: string | Array<string>): string;
|
|
143
|
-
|
|
144
|
-
* Checks if a value is defined and not empty
|
|
145
|
-
*
|
|
146
|
-
* @param value - Value to check
|
|
147
|
-
* @returns True if value is defined and not empty
|
|
148
|
-
*/
|
|
149
|
-
declare function isDefined<T>(value: T | undefined | null): value is T;
|
|
57
|
+
|
|
150
58
|
/**
|
|
151
59
|
* Normalizes scope to an array
|
|
152
60
|
*
|
|
153
61
|
* @param scope - String or array of strings
|
|
154
62
|
* @returns Array of scope strings
|
|
155
63
|
*/
|
|
156
|
-
declare function normalizeScope(scope: string | Array<string
|
|
64
|
+
declare function normalizeScope(scope: Optional<string | Array<string>>): Array<string>;
|
|
157
65
|
|
|
158
|
-
export { type DialogState, type UseDialogStateReturn, createBlockerId,
|
|
66
|
+
export { type DialogState, type UseDialogStateReturn, createBlockerId, normalizeScope, resolveCondition, useBeforeUnload, useDialogState };
|
package/dist/index.js
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { isFunction, isArray } from '@okyrychenko-dev/type-utils';
|
|
3
|
+
export { isDefined } from '@okyrychenko-dev/type-utils';
|
|
2
4
|
|
|
3
5
|
// src/core/useBeforeUnload.ts
|
|
4
|
-
|
|
5
|
-
// src/core/utils.ts
|
|
6
6
|
function resolveCondition(condition) {
|
|
7
|
-
return
|
|
7
|
+
return isFunction(condition) ? condition() : condition;
|
|
8
8
|
}
|
|
9
9
|
function createBlockerId(prefix, scope) {
|
|
10
|
-
const scopePart = scope ? `-${
|
|
10
|
+
const scopePart = scope ? `-${isArray(scope) ? scope.join("-") : scope}` : "";
|
|
11
11
|
const timestamp = String(Date.now());
|
|
12
12
|
return `${prefix}${scopePart}-${timestamp}`;
|
|
13
13
|
}
|
|
14
|
-
function isDefined(value) {
|
|
15
|
-
return value !== void 0 && value !== null;
|
|
16
|
-
}
|
|
17
14
|
function normalizeScope(scope) {
|
|
18
15
|
if (!scope) {
|
|
19
16
|
return [];
|
|
20
17
|
}
|
|
21
|
-
return
|
|
18
|
+
return isArray(scope) ? scope : [scope];
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
// src/core/useBeforeUnload.ts
|
|
@@ -44,26 +41,40 @@ function useBeforeUnload(when, message = DEFAULT_UNLOAD_MESSAGE) {
|
|
|
44
41
|
function useDialogState() {
|
|
45
42
|
const [dialogState, setDialogState] = useState(null);
|
|
46
43
|
const resolveRef = useRef(null);
|
|
44
|
+
const mountedRef = useRef(true);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
mountedRef.current = true;
|
|
47
|
+
return () => {
|
|
48
|
+
mountedRef.current = false;
|
|
49
|
+
resolveRef.current?.(false);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
47
52
|
const confirm = useCallback((message) => {
|
|
53
|
+
if (!mountedRef.current) {
|
|
54
|
+
return Promise.resolve(false);
|
|
55
|
+
}
|
|
48
56
|
return new Promise((resolve) => {
|
|
49
57
|
if (resolveRef.current) {
|
|
50
58
|
resolveRef.current(false);
|
|
51
59
|
}
|
|
52
|
-
|
|
60
|
+
const settle = (confirmed) => {
|
|
61
|
+
if (resolveRef.current !== settle) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
resolveRef.current = null;
|
|
65
|
+
resolve(confirmed);
|
|
66
|
+
setDialogState(null);
|
|
67
|
+
};
|
|
68
|
+
resolveRef.current = settle;
|
|
53
69
|
setDialogState({
|
|
54
70
|
isOpen: true,
|
|
55
71
|
message,
|
|
56
|
-
resolve
|
|
72
|
+
resolve: settle
|
|
57
73
|
});
|
|
58
74
|
});
|
|
59
75
|
}, []);
|
|
60
76
|
const closeDialog = useCallback((confirmed) => {
|
|
61
|
-
|
|
62
|
-
if (resolve) {
|
|
63
|
-
resolve(confirmed);
|
|
64
|
-
}
|
|
65
|
-
resolveRef.current = null;
|
|
66
|
-
setDialogState(null);
|
|
77
|
+
resolveRef.current?.(confirmed);
|
|
67
78
|
}, []);
|
|
68
79
|
const onConfirm = useCallback(() => {
|
|
69
80
|
closeDialog(true);
|
|
@@ -79,6 +90,6 @@ function useDialogState() {
|
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
export { createBlockerId,
|
|
93
|
+
export { createBlockerId, normalizeScope, resolveCondition, useBeforeUnload, useDialogState };
|
|
83
94
|
//# sourceMappingURL=index.js.map
|
|
84
95
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/utils.ts","../src/core/useBeforeUnload.ts","../src/core/useDialogState.ts"],"names":[],"mappings":"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","file":"index.js","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"]}
|
|
1
|
+
{"version":3,"sources":["../src/core/utils.ts","../src/core/useBeforeUnload.ts","../src/core/useDialogState.ts"],"names":["useEffect"],"mappings":"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","file":"index.js","sourcesContent":["import { isArray, isFunction, isObject } from \"@okyrychenko-dev/type-utils\";\nimport type { Optional } from \"@okyrychenko-dev/type-utils\";\n\n/**\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 isFunction(condition) ? condition() : condition;\n}\n\ninterface Thenable {\n then: (...args: Array<unknown>) => unknown;\n}\n\nconst hasThen = (value: unknown): value is Thenable => isObject(value) && \"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\n return isFunction(value.then);\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: Optional<(message: string) => boolean | PromiseLike<boolean>>,\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\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 ? `-${isArray(scope) ? scope.join(\"-\") : scope}` : \"\";\n const timestamp = String(Date.now());\n\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 { isDefined } from \"@okyrychenko-dev/type-utils\";\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: Optional<string | Array<string>>): Array<string> {\n if (!scope) {\n return [];\n }\n\n return isArray(scope) ? scope : [scope];\n}\n\n/** Identifies protection independently of scope order and duplicate entries. */\nexport function createScopeKey(scope: Optional<string | Array<string>>): string {\n return JSON.stringify([...new Set(normalizeScope(scope))].sort());\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 */\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, useEffect, useRef, useState } from \"react\";\nimport type { Nullable } from \"@okyrychenko-dev/type-utils\";\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 /** Settle and close this dialog; repeated or stale calls have no effect */\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: Nullable<DialogState<T>>;\n\n /**\n * Function to use as onConfirm callback.\n * Resolves true on confirmation, false on cancellation, replacement or effect teardown.\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 */\nexport function useDialogState<T = string>(): UseDialogStateReturn<T> {\n const [dialogState, setDialogState] = useState<Nullable<DialogState<T>>>(null);\n const resolveRef = useRef<Nullable<DialogState<T>[\"resolve\"]>>(null);\n const mountedRef = useRef(true);\n\n useEffect(() => {\n mountedRef.current = true;\n\n return () => {\n mountedRef.current = false;\n resolveRef.current?.(false);\n };\n }, []);\n\n const confirm = useCallback((message: T): Promise<boolean> => {\n if (!mountedRef.current) {\n return Promise.resolve(false);\n }\n\n return new Promise<boolean>((resolve) => {\n if (resolveRef.current) {\n resolveRef.current(false);\n }\n\n const settle = (confirmed: boolean): void => {\n if (resolveRef.current !== settle) {\n return;\n }\n\n resolveRef.current = null;\n resolve(confirmed);\n\n setDialogState(null);\n };\n\n resolveRef.current = settle;\n\n setDialogState({\n isOpen: true,\n message,\n resolve: settle,\n });\n });\n }, []);\n\n const closeDialog = useCallback((confirmed: boolean) => {\n resolveRef.current?.(confirmed);\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"]}
|