@okyrychenko-dev/react-action-guard 0.4.0 → 0.5.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
 
@@ -225,49 +225,66 @@ function ApiComponent() {
225
225
  }
226
226
  ```
227
227
 
228
- #### `useConfirmableBlocker(blockerId, config, options)`
228
+ #### `useConfirmableBlocker(blockerId, config)`
229
229
 
230
- Creates a blocker that requires user confirmation before being removed.
230
+ Creates a confirmable action with UI blocking while the dialog is open or the action is running.
231
231
 
232
232
  **Parameters:**
233
233
 
234
234
  - `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
235
+ - `config: ConfirmableBlockerConfig` - Configuration object
236
+ - `confirmMessage: string` - Message to show in confirmation dialog
237
+ - `confirmTitle?: string` - Dialog title (default: "Confirm Action")
238
+ - `confirmButtonText?: string` - Confirm button label (default: "Confirm")
239
+ - `cancelButtonText?: string` - Cancel button label (default: "Cancel")
240
+ - `onConfirm: () => void | Promise<void>` - Callback when user confirms
241
+ - `onCancel?: () => void` - Callback when user cancels
242
+ - Plus all `BlockerConfig` properties (scope, reason, priority, timeout)
240
243
 
241
244
  **Returns:**
242
245
 
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
246
+ - `execute: () => void` - Opens the confirmation dialog
247
+ - `isDialogOpen: boolean` - Whether the dialog is open
248
+ - `isExecuting: boolean` - Whether the confirm action is running
249
+ - `confirmConfig: { title, message, confirmText, cancelText }` - UI-ready dialog config
250
+ - `onConfirm: () => Promise<void>` - Confirm handler to wire to your dialog
251
+ - `onCancel: () => void` - Cancel handler to wire to your dialog
246
252
 
247
253
  **Example:**
248
254
 
249
255
  ```jsx
250
- function UnsavedChangesGuard({ hasUnsavedChanges, discardChanges }) {
251
- const { isBlocking, requestRemoval } = useConfirmableBlocker(
252
- "unsaved-changes",
253
- {
254
- scope: "navigation",
255
- reason: "Unsaved changes",
256
+ function UnsavedChangesGuard({ discardChanges }) {
257
+ const {
258
+ execute,
259
+ isDialogOpen,
260
+ isExecuting,
261
+ confirmConfig,
262
+ onConfirm,
263
+ onCancel,
264
+ } = useConfirmableBlocker("unsaved-changes", {
265
+ scope: "navigation",
266
+ reason: "Unsaved changes",
267
+ confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
268
+ onConfirm: async () => {
269
+ await discardChanges();
256
270
  },
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
- );
271
+ });
265
272
 
266
273
  return (
267
- <div>
268
- {isBlocking && <UnsavedIndicator />}
269
- <button onClick={requestRemoval}>Leave</button>
270
- </div>
274
+ <>
275
+ <button onClick={execute}>Leave</button>
276
+ {isDialogOpen && (
277
+ <ConfirmDialog
278
+ title={confirmConfig.title}
279
+ message={confirmConfig.message}
280
+ confirmText={confirmConfig.confirmText}
281
+ cancelText={confirmConfig.cancelText}
282
+ onConfirm={onConfirm}
283
+ onCancel={onCancel}
284
+ />
285
+ )}
286
+ {isExecuting && <LoadingOverlay message="Processing..." />}
287
+ </>
271
288
  );
272
289
  }
273
290
  ```
@@ -411,17 +428,18 @@ function MicroFrontend() {
411
428
 
412
429
  #### `useUIBlockingStore`
413
430
 
414
- Direct access to the Zustand store for advanced use cases.
431
+ Direct access to the Zustand store for advanced use cases (requires a selector).
415
432
 
416
433
  **Methods:**
417
434
 
418
- - `addBlocker(id, config)` - Manually add a blocker
435
+ - `addBlocker(id, config)` - Manually add a blocker (re-adding with the same ID replaces config)
419
436
  - `removeBlocker(id)` - Manually remove a blocker
420
- - `updateBlocker(id, config)` - Update an existing blocker
421
437
  - `isBlocked(scope)` - Check if scope is blocked
422
438
  - `getBlockingInfo(scope)` - Get detailed blocking information
423
439
  - `clearAllBlockers()` - Remove all blockers
424
440
  - `clearBlockersForScope(scope)` - Remove blockers for specific scope
441
+ - `registerMiddleware(name, middleware)` - Register middleware manually
442
+ - `unregisterMiddleware(name)` - Unregister middleware manually
425
443
 
426
444
  **Example:**
427
445
 
@@ -429,7 +447,10 @@ Direct access to the Zustand store for advanced use cases.
429
447
  import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
430
448
 
431
449
  function AdvancedComponent() {
432
- const { addBlocker, removeBlocker, updateBlocker } = useUIBlockingStore();
450
+ const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
451
+ addBlocker: state.addBlocker,
452
+ removeBlocker: state.removeBlocker,
453
+ }));
433
454
 
434
455
  const startBlocking = () => {
435
456
  addBlocker("custom-blocker", {
@@ -440,7 +461,10 @@ function AdvancedComponent() {
440
461
  };
441
462
 
442
463
  const updatePriority = () => {
443
- updateBlocker("custom-blocker", {
464
+ // Re-add with the same ID to update the config
465
+ addBlocker("custom-blocker", {
466
+ scope: ["form", "navigation"],
467
+ reason: "Critical operation in progress",
444
468
  priority: 200,
445
469
  });
446
470
  };
@@ -459,6 +483,26 @@ function AdvancedComponent() {
459
483
  }
460
484
  ```
461
485
 
486
+ For non-hook contexts (tests, utilities, event handlers), use `uiBlockingStoreApi`:
487
+
488
+ ```jsx
489
+ import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard";
490
+
491
+ uiBlockingStoreApi.getState().addBlocker("server-call", {
492
+ scope: "global",
493
+ reason: "Server call running",
494
+ });
495
+ ```
496
+
497
+ ### Store Toolkit Helpers
498
+
499
+ Advanced store helpers are re-exported from the internal toolkit for custom setups:
500
+
501
+ - `createShallowStore`
502
+ - `createStoreToolkit`
503
+ - `createStoreProvider`
504
+ - `createResolvedStoreHooks`
505
+
462
506
  ## Middleware System
463
507
 
464
508
  The library includes a powerful middleware system that allows you to hook into blocker lifecycle events for analytics, logging, and performance monitoring.
@@ -524,6 +568,10 @@ configureMiddleware([
524
568
  ]);
525
569
  ```
526
570
 
571
+ Note: `configureMiddleware` registers middleware on the global store. If you use `UIBlockingProvider`, register middleware via the provider's `middlewares` prop instead.
572
+
573
+ Note: Middleware action types include `update` and `cancel`, but the store currently emits `add`, `remove`, and `timeout`.
574
+
527
575
  ### Custom Middleware
528
576
 
529
577
  Create your own middleware to handle blocker events:
@@ -599,23 +647,48 @@ import type {
599
647
  // Core types
600
648
  BlockerConfig,
601
649
  BlockerInfo,
602
- UIBlockingState,
650
+ UIBlockingStore,
651
+ UIBlockingStoreState,
603
652
 
604
653
  // Hook types
605
- ConfirmableBlockerOptions,
654
+ ConfirmableBlockerConfig,
655
+ ConfirmDialogConfig,
656
+ UseConfirmableBlockerReturn,
606
657
  ScheduledBlockerConfig,
607
658
  ConditionalBlockerConfig,
608
659
  BlockingSchedule,
660
+ UseAsyncActionOptions,
609
661
 
610
662
  // Middleware types
611
663
  Middleware,
612
664
  MiddlewareContext,
613
665
  AnalyticsConfig,
614
666
  AnalyticsProvider,
615
- PerformanceMiddlewareConfig,
667
+ PerformanceConfig,
668
+
669
+ // Type-safe scopes
670
+ BlockerConfigTyped,
671
+ DefaultScopes,
672
+ ScopeValue,
616
673
  } from "@okyrychenko-dev/react-action-guard";
617
674
  ```
618
675
 
676
+ ## Type-Safe Scopes
677
+
678
+ Create typed versions of the hooks to prevent scope typos at compile time:
679
+
680
+ ```typescript
681
+ import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
682
+
683
+ type AppScopes = "global" | "form" | "navigation" | "checkout";
684
+
685
+ const { useBlocker, useIsBlocked, useAsyncAction, useBlockingInfo } =
686
+ createTypedHooks<AppScopes>();
687
+
688
+ useBlocker("save", { scope: "form" }); // OK
689
+ useBlocker("save", { scope: "typo" }); // Type error
690
+ ```
691
+
619
692
  ## Use Cases
620
693
 
621
694
  ### Loading States
@@ -670,22 +743,22 @@ function FormWithUnsavedWarning() {
670
743
  const [formData, setFormData] = useState({});
671
744
  const [hasChanges, setHasChanges] = useState(false);
672
745
 
673
- const { requestRemoval } = useConfirmableBlocker(
674
- "unsaved-form",
675
- {
676
- scope: "navigation",
677
- reason: "Unsaved form data",
678
- priority: 100,
746
+ const {
747
+ execute,
748
+ isDialogOpen,
749
+ confirmConfig,
750
+ onConfirm,
751
+ onCancel,
752
+ } = useConfirmableBlocker("unsaved-form", {
753
+ scope: "navigation",
754
+ reason: "Unsaved form data",
755
+ priority: 100,
756
+ confirmMessage: "You have unsaved changes. Discard them?",
757
+ onConfirm: () => {
758
+ setFormData({});
759
+ setHasChanges(false);
679
760
  },
680
- {
681
- enabled: hasChanges,
682
- confirmMessage: "You have unsaved changes. Discard them?",
683
- onConfirm: () => {
684
- setFormData({});
685
- setHasChanges(false);
686
- },
687
- }
688
- );
761
+ });
689
762
 
690
763
  return (
691
764
  <form>
@@ -695,7 +768,17 @@ function FormWithUnsavedWarning() {
695
768
  setHasChanges(true);
696
769
  }}
697
770
  />
698
- <button onClick={requestRemoval}>Cancel</button>
771
+ <button type="button" onClick={execute}>Cancel</button>
772
+ {isDialogOpen && (
773
+ <ConfirmDialog
774
+ title={confirmConfig.title}
775
+ message={confirmConfig.message}
776
+ confirmText={confirmConfig.confirmText}
777
+ cancelText={confirmConfig.cancelText}
778
+ onConfirm={onConfirm}
779
+ onCancel={onCancel}
780
+ />
781
+ )}
699
782
  </form>
700
783
  );
701
784
  }
package/dist/index.cjs CHANGED
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  var react = require('react');
4
+ var reactZustandToolkit = require('@okyrychenko-dev/react-zustand-toolkit');
4
5
  var zustand = require('zustand');
5
- var middleware = require('zustand/middleware');
6
- var shallow = require('zustand/shallow');
6
+ require('zustand/react/shallow');
7
7
  var jsxRuntime = require('react/jsx-runtime');
8
+ var shallow = require('zustand/shallow');
9
+ var middleware = require('zustand/middleware');
8
10
 
9
11
  // src/hooks/useAsyncAction/useAsyncAction.ts
10
12
 
@@ -14,13 +16,6 @@ var DEFAULT_REASON = "Unknown";
14
16
  var DEFAULT_PRIORITY = 0;
15
17
  var ASYNC_ACTION_PRIORITY = 20;
16
18
  var DEVTOOLS_NAME = "UIBlocking";
17
- function createShallowStore(storeCreator) {
18
- const storeApi = zustand.createStore(storeCreator);
19
- const useScopedStore = (selector) => {
20
- return zustand.useStore(storeApi, shallow.useShallow(selector));
21
- };
22
- return { useStore: useScopedStore, useStoreApi: storeApi };
23
- }
24
19
  function normalizeScopeToArray(scope) {
25
20
  if (typeof scope === "string") {
26
21
  return [scope];
@@ -221,44 +216,33 @@ var createUIBlockingActions = (set, get) => ({
221
216
  });
222
217
  }
223
218
  });
224
- var UIBlockingContext = react.createContext(null);
219
+ var {
220
+ Provider: BaseUIBlockingProvider,
221
+ useContext: useUIBlockingContext,
222
+ useContextStore: useUIBlockingStoreFromContext,
223
+ useIsInsideProvider: useIsInsideUIBlockingProvider,
224
+ useOptionalContext
225
+ } = reactZustandToolkit.createStoreProvider(createUIBlockingActions, "UIBlocking");
225
226
  function UIBlockingProvider({
226
227
  children,
227
228
  enableDevtools = process.env.NODE_ENV === "development",
228
229
  devtoolsName = "UIBlocking",
229
230
  middlewares = []
230
231
  }) {
231
- const middlewaresRef = react.useRef(middlewares);
232
- const store = react.useMemo(() => {
233
- const baseStore = enableDevtools ? zustand.createStore(
234
- middleware.devtools(createUIBlockingActions, {
235
- name: devtoolsName,
236
- enabled: enableDevtools
237
- })
238
- ) : zustand.createStore(createUIBlockingActions);
239
- middlewaresRef.current.forEach((mw, index) => {
240
- baseStore.getState().registerMiddleware(`provider-middleware-${index.toString()}`, mw);
232
+ const handleStoreCreate = (store) => {
233
+ middlewares.forEach((mw, index) => {
234
+ store.getState().registerMiddleware(`provider-middleware-${index.toString()}`, mw);
241
235
  });
242
- return baseStore;
243
- }, [enableDevtools, devtoolsName]);
244
- return /* @__PURE__ */ jsxRuntime.jsx(UIBlockingContext.Provider, { value: store, children });
245
- }
246
- function useUIBlockingContext() {
247
- const store = react.useContext(UIBlockingContext);
248
- if (!store) {
249
- throw new Error(
250
- "useUIBlockingContext must be used within UIBlockingProvider. Either wrap your app in <UIBlockingProvider> or use the global store via useUIBlockingStore."
251
- );
252
- }
253
- return store;
254
- }
255
- function useIsInsideUIBlockingProvider() {
256
- const store = react.useContext(UIBlockingContext);
257
- return store !== null;
258
- }
259
- function useUIBlockingStoreFromContext(selector) {
260
- const store = useUIBlockingContext();
261
- return zustand.useStore(store, shallow.useShallow(selector));
236
+ };
237
+ return /* @__PURE__ */ jsxRuntime.jsx(
238
+ BaseUIBlockingProvider,
239
+ {
240
+ enableDevtools,
241
+ devtoolsName,
242
+ onStoreCreate: handleStoreCreate,
243
+ children
244
+ }
245
+ );
262
246
  }
263
247
 
264
248
  // src/store/uiBlockingStore.config.ts
@@ -268,11 +252,14 @@ var devtoolsConfig = {
268
252
  };
269
253
 
270
254
  // src/store/uiBlockingStore.store.ts
271
- var { useStore: useUIBlockingStore, useStoreApi: uiBlockingStoreApi } = createShallowStore(middleware.devtools(createUIBlockingActions, devtoolsConfig));
255
+ var { useStore: useUIBlockingStore, useStoreApi: uiBlockingStoreApi } = reactZustandToolkit.createShallowStore(middleware.devtools(createUIBlockingActions, devtoolsConfig));
272
256
 
273
257
  // src/context/useResolvedStore.ts
258
+ function useOptionalContextStore() {
259
+ return useOptionalContext();
260
+ }
274
261
  function useResolvedStore() {
275
- const contextStore = react.useContext(UIBlockingContext);
262
+ const contextStore = useOptionalContextStore();
276
263
  return contextStore ?? uiBlockingStoreApi;
277
264
  }
278
265
  function useResolvedStoreWithSelector(selector) {
@@ -719,6 +706,10 @@ function createTypedHooks() {
719
706
  };
720
707
  }
721
708
 
709
+ Object.defineProperty(exports, "createShallowStore", {
710
+ enumerable: true,
711
+ get: function () { return reactZustandToolkit.createShallowStore; }
712
+ });
722
713
  exports.UIBlockingProvider = UIBlockingProvider;
723
714
  exports.configureMiddleware = configureMiddleware;
724
715
  exports.createAnalyticsMiddleware = createAnalyticsMiddleware;