@okyrychenko-dev/react-action-guard 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,7 @@ function MyComponent() {
53
53
 
54
54
  ## Documentation
55
55
 
56
- 📚 **[Interactive Storybook Documentation](https://your-storybook-url.com)** - Explore live examples and detailed guides for all hooks
56
+ 📚 **Interactive Storybook Documentation** - Run locally to explore live examples and detailed guides for all hooks
57
57
 
58
58
  To run Storybook locally:
59
59
 
@@ -154,10 +154,12 @@ Gets detailed information about all active blockers for a specific scope.
154
154
  **BlockerInfo:**
155
155
 
156
156
  - `id: string` - Unique identifier of the blocker
157
- - `reason?: string` - Reason for blocking
158
- - `priority: number` - Priority level (0-100)
157
+ - `reason: string` - Reason for blocking (defaults to "Unknown")
158
+ - `priority: number` - Priority level (higher = higher priority, minimum value is 0)
159
159
  - `scope: string | string[]` - Scope(s) being blocked
160
- - `timestamp: number` - When the blocker was added
160
+ - `timestamp: number` - When the blocker was added (milliseconds since epoch)
161
+ - `timeout?: number` - Optional timeout duration in milliseconds
162
+ - `onTimeout?: (blockerId: string) => void` - Optional callback when timeout expires
161
163
 
162
164
  **Example:**
163
165
 
@@ -225,49 +227,66 @@ function ApiComponent() {
225
227
  }
226
228
  ```
227
229
 
228
- #### `useConfirmableBlocker(blockerId, config, options)`
230
+ #### `useConfirmableBlocker(blockerId, config)`
229
231
 
230
- Creates a blocker that requires user confirmation before being removed.
232
+ Creates a confirmable action with UI blocking while the dialog is open or the action is running.
231
233
 
232
234
  **Parameters:**
233
235
 
234
236
  - `blockerId: string` - Unique identifier for the blocker
235
- - `config: BlockerConfig` - Configuration object
236
- - `options: ConfirmableBlockerOptions`
237
- - `enabled?: boolean` - Whether the blocker is active (default: true)
238
- - `confirmMessage?: string` - Message to show in confirmation dialog
239
- - `onConfirm?: () => void | Promise<void>` - Callback when user confirms
237
+ - `config: ConfirmableBlockerConfig` - Configuration object
238
+ - `confirmMessage: string` - Message to show in confirmation dialog
239
+ - `confirmTitle?: string` - Dialog title (default: "Confirm Action")
240
+ - `confirmButtonText?: string` - Confirm button label (default: "Confirm")
241
+ - `cancelButtonText?: string` - Cancel button label (default: "Cancel")
242
+ - `onConfirm: () => void | Promise<void>` - Callback when user confirms
243
+ - `onCancel?: () => void` - Callback when user cancels
244
+ - Plus all `BlockerConfig` properties (scope, reason, priority, timeout)
240
245
 
241
246
  **Returns:**
242
247
 
243
- - `isBlocking: boolean` - Whether the blocker is currently active
244
- - `cancel: () => void` - Manually remove the blocker without confirmation
245
- - `requestRemoval: () => Promise<boolean>` - Request removal with confirmation
248
+ - `execute: () => void` - Opens the confirmation dialog
249
+ - `isDialogOpen: boolean` - Whether the dialog is open
250
+ - `isExecuting: boolean` - Whether the confirm action is running
251
+ - `confirmConfig: { title, message, confirmText, cancelText }` - UI-ready dialog config
252
+ - `onConfirm: () => Promise<void>` - Confirm handler to wire to your dialog
253
+ - `onCancel: () => void` - Cancel handler to wire to your dialog
246
254
 
247
255
  **Example:**
248
256
 
249
257
  ```jsx
250
- function UnsavedChangesGuard({ hasUnsavedChanges, discardChanges }) {
251
- const { isBlocking, requestRemoval } = useConfirmableBlocker(
252
- "unsaved-changes",
253
- {
254
- scope: "navigation",
255
- reason: "Unsaved changes",
258
+ function UnsavedChangesGuard({ discardChanges }) {
259
+ const {
260
+ execute,
261
+ isDialogOpen,
262
+ isExecuting,
263
+ confirmConfig,
264
+ onConfirm,
265
+ onCancel,
266
+ } = useConfirmableBlocker("unsaved-changes", {
267
+ scope: "navigation",
268
+ reason: "Unsaved changes",
269
+ confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
270
+ onConfirm: async () => {
271
+ await discardChanges();
256
272
  },
257
- {
258
- enabled: hasUnsavedChanges,
259
- confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
260
- onConfirm: async () => {
261
- await discardChanges();
262
- },
263
- }
264
- );
273
+ });
265
274
 
266
275
  return (
267
- <div>
268
- {isBlocking && <UnsavedIndicator />}
269
- <button onClick={requestRemoval}>Leave</button>
270
- </div>
276
+ <>
277
+ <button onClick={execute}>Leave</button>
278
+ {isDialogOpen && (
279
+ <ConfirmDialog
280
+ title={confirmConfig.title}
281
+ message={confirmConfig.message}
282
+ confirmText={confirmConfig.confirmText}
283
+ cancelText={confirmConfig.cancelText}
284
+ onConfirm={onConfirm}
285
+ onCancel={onCancel}
286
+ />
287
+ )}
288
+ {isExecuting && <LoadingOverlay message="Processing..." />}
289
+ </>
271
290
  );
272
291
  }
273
292
  ```
@@ -411,17 +430,25 @@ function MicroFrontend() {
411
430
 
412
431
  #### `useUIBlockingStore`
413
432
 
414
- Direct access to the Zustand store for advanced use cases.
433
+ Direct access to the Zustand store for advanced use cases (requires a selector).
415
434
 
416
435
  **Methods:**
417
436
 
418
- - `addBlocker(id, config)` - Manually add a blocker
437
+ - `addBlocker(id, config)` - Manually add a blocker (re-adding with the same ID replaces config)
438
+ - `updateBlocker(id, config)` - Update blocker metadata (timeout restarts if new `timeout` value provided)
419
439
  - `removeBlocker(id)` - Manually remove a blocker
420
- - `updateBlocker(id, config)` - Update an existing blocker
421
440
  - `isBlocked(scope)` - Check if scope is blocked
422
441
  - `getBlockingInfo(scope)` - Get detailed blocking information
423
- - `clearAllBlockers()` - Remove all blockers
424
- - `clearBlockersForScope(scope)` - Remove blockers for specific scope
442
+ - `clearAllBlockers()` - Remove all blockers (emits `"clear"` middleware event)
443
+ - `clearBlockersForScope(scope)` - Remove blockers for specific scope (emits `"clear_scope"` middleware event)
444
+ - `registerMiddleware(name, middleware)` - Register middleware manually
445
+ - `unregisterMiddleware(name)` - Unregister middleware manually
446
+
447
+ **Note about `updateBlocker` and timeouts:**
448
+ - If you pass a **new** `timeout` value, the timer will be **restarted**
449
+ - If you **don't** pass `timeout`, the existing timer continues unchanged
450
+ - Set `timeout: 0` to **clear** an existing timeout
451
+ - `addBlocker` always resets/creates a new timeout timer when called with the same ID
425
452
 
426
453
  **Example:**
427
454
 
@@ -429,7 +456,11 @@ Direct access to the Zustand store for advanced use cases.
429
456
  import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
430
457
 
431
458
  function AdvancedComponent() {
432
- const { addBlocker, removeBlocker, updateBlocker } = useUIBlockingStore();
459
+ const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore((state) => ({
460
+ addBlocker: state.addBlocker,
461
+ updateBlocker: state.updateBlocker,
462
+ removeBlocker: state.removeBlocker,
463
+ }));
433
464
 
434
465
  const startBlocking = () => {
435
466
  addBlocker("custom-blocker", {
@@ -440,9 +471,13 @@ function AdvancedComponent() {
440
471
  };
441
472
 
442
473
  const updatePriority = () => {
443
- updateBlocker("custom-blocker", {
444
- priority: 200,
445
- });
474
+ // Update metadata - timeout continues if not changed
475
+ updateBlocker("custom-blocker", { priority: 200 });
476
+ };
477
+
478
+ const extendTimeout = () => {
479
+ // Update timeout - timer restarts with new value
480
+ updateBlocker("custom-blocker", { timeout: 60000 });
446
481
  };
447
482
 
448
483
  const stopBlocking = () => {
@@ -459,10 +494,54 @@ function AdvancedComponent() {
459
494
  }
460
495
  ```
461
496
 
497
+ For non-hook contexts (tests, utilities, event handlers), use `uiBlockingStoreApi`:
498
+
499
+ ```jsx
500
+ import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard";
501
+
502
+ uiBlockingStoreApi.getState().addBlocker("server-call", {
503
+ scope: "global",
504
+ reason: "Server call running",
505
+ });
506
+ ```
507
+
508
+ ### Store Toolkit Helpers
509
+
510
+ Advanced store helpers are re-exported from the internal toolkit for custom setups:
511
+
512
+ - `createShallowStore`
513
+ - `createStoreToolkit`
514
+ - `createStoreProvider`
515
+ - `createResolvedStoreHooks`
516
+
462
517
  ## Middleware System
463
518
 
464
519
  The library includes a powerful middleware system that allows you to hook into blocker lifecycle events for analytics, logging, and performance monitoring.
465
520
 
521
+ ### Middleware Actions
522
+
523
+ Middleware receives events for the following actions:
524
+
525
+ - `"add"` - Blocker was added
526
+ - `"update"` - Blocker metadata was updated
527
+ - `"remove"` - Blocker was removed
528
+ - `"timeout"` - Blocker was auto-removed due to timeout
529
+ - `"clear"` - All blockers were cleared (includes `count` field)
530
+ - `"clear_scope"` - Blockers for specific scope were cleared (includes `scope` and `count` fields)
531
+
532
+ **MiddlewareContext type:**
533
+ ```typescript
534
+ {
535
+ action: "add" | "update" | "remove" | "timeout" | "clear" | "clear_scope";
536
+ blockerId: string;
537
+ config?: BlockerConfig;
538
+ timestamp: number;
539
+ prevState?: BlockerConfig; // Available for "update" and "remove"
540
+ scope?: string; // Available for "clear_scope"
541
+ count?: number; // Available for "clear" and "clear_scope"
542
+ }
543
+ ```
544
+
466
545
  ### Built-in Middleware
467
546
 
468
547
  #### Analytics Middleware
@@ -524,6 +603,8 @@ configureMiddleware([
524
603
  ]);
525
604
  ```
526
605
 
606
+ Note: `configureMiddleware` registers middleware on the global store. If you use `UIBlockingProvider`, register middleware via the provider's `middlewares` prop instead.
607
+
527
608
  ### Custom Middleware
528
609
 
529
610
  Create your own middleware to handle blocker events:
@@ -532,14 +613,20 @@ Create your own middleware to handle blocker events:
532
613
  import { configureMiddleware } from "@okyrychenko-dev/react-action-guard";
533
614
 
534
615
  const myCustomMiddleware = (context) => {
535
- const { action, blockerId, config, timestamp } = context;
616
+ const { action, blockerId, config, timestamp, scope, count } = context;
536
617
 
537
618
  if (action === "add") {
538
619
  console.log(`Blocker added: ${blockerId}`, config);
539
- } else if (action === "remove") {
540
- console.log(`Blocker removed: ${blockerId}`);
541
620
  } else if (action === "update") {
542
621
  console.log(`Blocker updated: ${blockerId}`, config);
622
+ } else if (action === "remove") {
623
+ console.log(`Blocker removed: ${blockerId}`);
624
+ } else if (action === "timeout") {
625
+ console.log(`Blocker timed out: ${blockerId}`);
626
+ } else if (action === "clear") {
627
+ console.log(`All blockers cleared (${count} total)`);
628
+ } else if (action === "clear_scope") {
629
+ console.log(`Cleared ${count} blocker(s) for scope: ${scope}`);
543
630
  }
544
631
  };
545
632
 
@@ -599,23 +686,48 @@ import type {
599
686
  // Core types
600
687
  BlockerConfig,
601
688
  BlockerInfo,
602
- UIBlockingState,
689
+ UIBlockingStore,
690
+ UIBlockingStoreState,
603
691
 
604
692
  // Hook types
605
- ConfirmableBlockerOptions,
693
+ ConfirmableBlockerConfig,
694
+ ConfirmDialogConfig,
695
+ UseConfirmableBlockerReturn,
606
696
  ScheduledBlockerConfig,
607
697
  ConditionalBlockerConfig,
608
698
  BlockingSchedule,
699
+ UseAsyncActionOptions,
609
700
 
610
701
  // Middleware types
611
702
  Middleware,
612
703
  MiddlewareContext,
613
704
  AnalyticsConfig,
614
705
  AnalyticsProvider,
615
- PerformanceMiddlewareConfig,
706
+ PerformanceConfig,
707
+
708
+ // Type-safe scopes
709
+ BlockerConfigTyped,
710
+ DefaultScopes,
711
+ ScopeValue,
616
712
  } from "@okyrychenko-dev/react-action-guard";
617
713
  ```
618
714
 
715
+ ## Type-Safe Scopes
716
+
717
+ Create typed versions of the hooks to prevent scope typos at compile time:
718
+
719
+ ```typescript
720
+ import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
721
+
722
+ type AppScopes = "global" | "form" | "navigation" | "checkout";
723
+
724
+ const { useBlocker, useIsBlocked, useAsyncAction, useBlockingInfo } =
725
+ createTypedHooks<AppScopes>();
726
+
727
+ useBlocker("save", { scope: "form" }); // OK
728
+ useBlocker("save", { scope: "typo" }); // Type error
729
+ ```
730
+
619
731
  ## Use Cases
620
732
 
621
733
  ### Loading States
@@ -670,22 +782,22 @@ function FormWithUnsavedWarning() {
670
782
  const [formData, setFormData] = useState({});
671
783
  const [hasChanges, setHasChanges] = useState(false);
672
784
 
673
- const { requestRemoval } = useConfirmableBlocker(
674
- "unsaved-form",
675
- {
676
- scope: "navigation",
677
- reason: "Unsaved form data",
678
- priority: 100,
785
+ const {
786
+ execute,
787
+ isDialogOpen,
788
+ confirmConfig,
789
+ onConfirm,
790
+ onCancel,
791
+ } = useConfirmableBlocker("unsaved-form", {
792
+ scope: "navigation",
793
+ reason: "Unsaved form data",
794
+ priority: 100,
795
+ confirmMessage: "You have unsaved changes. Discard them?",
796
+ onConfirm: () => {
797
+ setFormData({});
798
+ setHasChanges(false);
679
799
  },
680
- {
681
- enabled: hasChanges,
682
- confirmMessage: "You have unsaved changes. Discard them?",
683
- onConfirm: () => {
684
- setFormData({});
685
- setHasChanges(false);
686
- },
687
- }
688
- );
800
+ });
689
801
 
690
802
  return (
691
803
  <form>
@@ -695,7 +807,17 @@ function FormWithUnsavedWarning() {
695
807
  setHasChanges(true);
696
808
  }}
697
809
  />
698
- <button onClick={requestRemoval}>Cancel</button>
810
+ <button type="button" onClick={execute}>Cancel</button>
811
+ {isDialogOpen && (
812
+ <ConfirmDialog
813
+ title={confirmConfig.title}
814
+ message={confirmConfig.message}
815
+ confirmText={confirmConfig.confirmText}
816
+ cancelText={confirmConfig.cancelText}
817
+ onConfirm={onConfirm}
818
+ onCancel={onCancel}
819
+ />
820
+ )}
699
821
  </form>
700
822
  );
701
823
  }
@@ -804,6 +926,97 @@ function StorageQuotaGuard() {
804
926
  }
805
927
  ```
806
928
 
929
+ ### Clearing Blockers on Navigation
930
+
931
+ Clear blockers when navigating away or on specific events:
932
+
933
+ ```jsx
934
+ import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
935
+ import { useEffect } from "react";
936
+
937
+ function CheckoutPage() {
938
+ const { clearBlockersForScope, clearAllBlockers } = useUIBlockingStore(
939
+ (state) => ({
940
+ clearBlockersForScope: state.clearBlockersForScope,
941
+ clearAllBlockers: state.clearAllBlockers,
942
+ })
943
+ );
944
+
945
+ // Clear checkout-specific blockers when leaving the page
946
+ useEffect(() => {
947
+ return () => {
948
+ // Clean up checkout blockers on unmount
949
+ clearBlockersForScope("checkout");
950
+ };
951
+ }, [clearBlockersForScope]);
952
+
953
+ const handleCancelOrder = () => {
954
+ // Clear all blockers when user explicitly cancels
955
+ clearAllBlockers();
956
+ navigate("/");
957
+ };
958
+
959
+ return (
960
+ <div>
961
+ <CheckoutForm />
962
+ <button onClick={handleCancelOrder}>Cancel Order</button>
963
+ </div>
964
+ );
965
+ }
966
+ ```
967
+
968
+ ### Managing Session Timeouts with Dynamic Updates
969
+
970
+ Extend or update blocker timeouts dynamically:
971
+
972
+ ```jsx
973
+ import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
974
+
975
+ function SessionManager() {
976
+ const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore(
977
+ (state) => ({
978
+ addBlocker: state.addBlocker,
979
+ updateBlocker: state.updateBlocker,
980
+ removeBlocker: state.removeBlocker,
981
+ })
982
+ );
983
+
984
+ const startSession = () => {
985
+ addBlocker("session-timeout", {
986
+ scope: "global",
987
+ reason: "Session expiring soon",
988
+ priority: 50,
989
+ timeout: 300000, // 5 minutes
990
+ onTimeout: () => {
991
+ logout();
992
+ showNotification("Session expired");
993
+ },
994
+ });
995
+ };
996
+
997
+ const extendSession = () => {
998
+ // Extend timeout - timer restarts with new value
999
+ updateBlocker("session-timeout", {
1000
+ timeout: 600000, // 10 minutes
1001
+ reason: "Session extended",
1002
+ });
1003
+ };
1004
+
1005
+ const endSession = () => {
1006
+ removeBlocker("session-timeout");
1007
+ logout();
1008
+ };
1009
+
1010
+ return (
1011
+ <div>
1012
+ <button onClick={startSession}>Start Session</button>
1013
+ <button onClick={extendSession}>Extend Session</button>
1014
+ <button onClick={endSession}>Logout</button>
1015
+ </div>
1016
+ );
1017
+ }
1018
+ ```
1019
+
807
1020
  ## Development
808
1021
 
809
1022
  ```bash