@codeleap/portals 6.3.0 → 6.8.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.
Files changed (41) hide show
  1. package/dist/factors/alert.d.ts +44 -0
  2. package/dist/factors/alert.d.ts.map +1 -0
  3. package/dist/factors/bottomSheet.d.ts +61 -0
  4. package/dist/factors/bottomSheet.d.ts.map +1 -0
  5. package/dist/factors/drawer.d.ts +32 -0
  6. package/dist/factors/drawer.d.ts.map +1 -0
  7. package/dist/factors/modal.d.ts +32 -0
  8. package/dist/factors/modal.d.ts.map +1 -0
  9. package/dist/globals.d.ts +19 -0
  10. package/dist/globals.d.ts.map +1 -0
  11. package/dist/index.d.ts +13 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/lib/Portal.d.ts +121 -0
  14. package/dist/lib/Portal.d.ts.map +1 -0
  15. package/dist/lib/PortalRegistry.d.ts +59 -0
  16. package/dist/lib/PortalRegistry.d.ts.map +1 -0
  17. package/dist/lib/PortalRequest.d.ts +44 -0
  18. package/dist/lib/PortalRequest.d.ts.map +1 -0
  19. package/dist/lib/PortalState.d.ts +83 -0
  20. package/dist/lib/PortalState.d.ts.map +1 -0
  21. package/dist/types/index.d.ts +2 -0
  22. package/dist/types/index.d.ts.map +1 -0
  23. package/dist/types/misc.d.ts +7 -0
  24. package/dist/types/misc.d.ts.map +1 -0
  25. package/dist/types/portal.d.ts +40 -0
  26. package/dist/types/portal.d.ts.map +1 -0
  27. package/dist/utils.d.ts +12 -0
  28. package/dist/utils.d.ts.map +1 -0
  29. package/package.json +25 -11
  30. package/src/factors/alert.ts +20 -60
  31. package/src/factors/bottomSheet.ts +20 -3
  32. package/src/factors/drawer.ts +11 -1
  33. package/src/factors/modal.ts +11 -1
  34. package/src/globals.ts +6 -0
  35. package/src/lib/Portal.tsx +62 -14
  36. package/src/lib/PortalRequest.ts +5 -5
  37. package/src/lib/PortalState.ts +64 -25
  38. package/src/types/misc.ts +2 -0
  39. package/src/types/portal.ts +11 -5
  40. package/src/utils.ts +9 -0
  41. package/package.json.bak +0 -32
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Generates a random 7-character alphanumeric ID.
3
+ * @returns Random ID string
4
+ */
5
+ export declare const randomId: () => string;
6
+ /**
7
+ * Waits for one or more animation transitions to complete.
8
+ * @param count - Number of transitions to wait for (defaults to 1)
9
+ * @param duration - Duration per transition in milliseconds (defaults to 1000)
10
+ */
11
+ export declare function awaitTransition(count?: number, duration?: number): Promise<void>;
12
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,eAAO,MAAM,QAAQ,cAEpB,CAAA;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,SAAO,iBAIpE"}
package/package.json CHANGED
@@ -1,7 +1,20 @@
1
1
  {
2
2
  "name": "@codeleap/portals",
3
- "version": "6.3.0",
3
+ "version": "6.8.0",
4
4
  "main": "src/index.ts",
5
+ "types": "dist/index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "source": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
5
18
  "license": "UNLICENSED",
6
19
  "repository": {
7
20
  "url": "https://github.com/codeleap-uk/internal-libs-monorepo.git",
@@ -9,24 +22,25 @@
9
22
  "directory": "packages/portals"
10
23
  },
11
24
  "devDependencies": {
12
- "@codeleap/types": "6.3.0",
13
- "@codeleap/config": "6.3.0",
14
- "@codeleap/utils": "6.3.0",
15
- "@codeleap/logger": "6.3.0",
25
+ "@codeleap/types": "6.8.0",
26
+ "@codeleap/config": "6.8.0",
27
+ "@codeleap/utils": "6.8.0",
28
+ "@codeleap/logger": "6.8.0",
16
29
  "ts-node-dev": "1.1.8"
17
30
  },
18
31
  "scripts": {
19
- "build": "echo 'No build needed'",
32
+ "build": "tsc --build tsconfig.build.json",
33
+ "typecheck": "bun tsc --noEmit -p ./tsconfig.json",
20
34
  "lint": "eslint -c .eslintrc.js --fix \"./src/**/*.{ts,tsx,js,jsx}\"",
21
35
  "run-sc": "tsnd --transpile-only"
22
36
  },
23
37
  "peerDependencies": {
24
- "@codeleap/types": "6.3.0",
25
- "@codeleap/utils": "6.3.0",
26
- "@codeleap/logger": "6.3.0",
27
- "typescript": "5.5.2",
38
+ "@codeleap/types": "6.8.0",
39
+ "@codeleap/utils": "6.8.0",
40
+ "@codeleap/logger": "6.8.0",
41
+ "typescript": "6.0.3",
28
42
  "react": "19.1.0",
29
43
  "nanostores": "*",
30
44
  "@nanostores/react": "*"
31
45
  }
32
- }
46
+ }
@@ -1,14 +1,17 @@
1
1
  import { AnyFunction } from '@codeleap/types'
2
2
  import { Modal } from './modal'
3
3
 
4
+ /** Discriminant for alert categories used to control icon/style in the wrapper component. */
4
5
  export type AlertType = 'info' | 'error' | 'warn' | 'ask' | 'custom'
5
6
 
7
+ /** A single button option rendered inside an alert dialog. */
6
8
  export type AlertOption = {
7
9
  text: string
8
10
  onPress?: AnyFunction
9
11
  style?: 'destructive' | 'outline' | 'cancel' | 'default' | 'minimal'
10
12
  }
11
13
 
14
+ /** Configuration options for displaying an alert dialog. */
12
15
  export type AlertOptions = {
13
16
  title?: string
14
17
  body?: string
@@ -22,88 +25,45 @@ export type AlertOptions = {
22
25
  * Provides convenient methods for common alert scenarios.
23
26
  */
24
27
  export class Alert {
25
- static modal: Modal<AlertOptions>
26
28
 
29
+ /**
30
+ * Returns the Modal instance used to display alerts.
31
+ * Must be implemented by the consuming app before calling any alert method.
32
+ * @throws Error if not implemented
33
+ */
27
34
  static openAlert(): Modal<AlertOptions> {
28
- return Alert.modal
35
+ throw new Error('Please implement Alert.openAlert to use the alert system')
29
36
  }
30
37
 
31
38
  private trigger(args: AlertOptions) {
32
39
  Alert.openAlert().open(args)
33
40
  }
34
41
 
35
- /**
36
- * Opens an alert with 'ask' type (for questions).
37
- * @param args - Alert options (defaults title to 'Quick question')
38
- */
42
+ /** Opens an alert with 'ask' type. */
39
43
  ask(args: AlertOptions) {
40
- const { title = 'Quick question', ...rest } = args
41
-
42
- this.trigger({
43
- ...rest,
44
- title,
45
- type: 'ask',
46
- })
44
+ this.trigger({ ...args, type: 'ask' })
47
45
  }
48
46
 
49
- /**
50
- * Opens an alert with 'error' type.
51
- * @param args - Alert options (defaults title to 'Whoops!' and body to 'Something went wrong')
52
- */
47
+ /** Opens an alert with 'error' type. */
53
48
  error(args: AlertOptions) {
54
- const { title = 'Whoops!', body = 'Something went wrong', ...rest } = args
55
-
56
- this.trigger({
57
- ...rest,
58
- title,
59
- body,
60
- type: 'error',
61
- })
49
+ this.trigger({ ...args, type: 'error' })
62
50
  }
63
51
 
64
- /**
65
- * Opens an alert with 'warn' type.
66
- * @param args - Alert options (defaults title to 'Hang on' and body to 'Are you sure?')
67
- */
52
+ /** Opens an alert with 'warn' type. */
68
53
  warn(args: AlertOptions) {
69
- const { title = 'Hang on', body = 'Are you sure?', ...rest } = args
70
-
71
- this.trigger({
72
- ...rest,
73
- title,
74
- body,
75
- type: 'warn',
76
- })
54
+ this.trigger({ ...args, type: 'warn' })
77
55
  }
78
56
 
79
- /**
80
- * Opens an alert with 'info' type.
81
- * @param args - Alert options (defaults title to 'Hang on' and body to 'Are you sure?')
82
- */
57
+ /** Opens an alert with 'info' type. */
83
58
  info(args: AlertOptions) {
84
- const { title = 'Hang on', body = 'Are you sure?', ...rest } = args
85
-
86
- this.trigger({
87
- ...rest,
88
- title,
89
- body,
90
- type: 'info',
91
- })
59
+ this.trigger({ ...args, type: 'info' })
92
60
  }
93
61
 
94
- /**
95
- * Opens a custom alert with user-defined options and type.
96
- * @param options - Custom alert options and additional properties
97
- */
62
+ /** Opens a custom alert with user-defined options and type. */
98
63
  custom<T>(options: Partial<AlertOptions> & T) {
99
- this.trigger({
100
- ...(options as any),
101
- type: 'custom',
102
- })
64
+ this.trigger({ ...(options as any), type: 'custom' })
103
65
  }
104
66
  }
105
67
 
106
- /**
107
- * Global alert instance for showing alerts throughout the app.
108
- */
68
+ /** Global alert instance for showing alerts throughout the app. */
109
69
  export const alert = new Alert()
@@ -15,22 +15,35 @@ import { ReactElement } from 'react'
15
15
  export class BottomSheet<Params = {}, Result = {}, Metadata = {}> extends Portal<Params, Result, Metadata, BottomSheetRef, BottomSheetWrapperProps> {
16
16
  displayName = 'BottomSheet'
17
17
 
18
- static WrapperComponent: (props: PortalWrapperProps<BottomSheetWrapperProps, BottomSheetRef>) => ReactElement = () => null
18
+ /** Wrapper component used to render bottom sheet UI. Must be assigned by the consuming app. */
19
+ static WrapperComponent: (props: PortalWrapperProps<BottomSheetWrapperProps, BottomSheetRef>) => ReactElement | null = () => null
19
20
 
21
+ /** Default transition duration in milliseconds for bottom sheet open/close animations. */
20
22
  static DEFAULT_TRANSITION_DURATION = 200
21
23
 
24
+ /** @inheritdoc */
22
25
  protected get defaultTransitionDuration() {
23
26
  return BottomSheet.DEFAULT_TRANSITION_DURATION
24
27
  }
25
28
 
29
+ /** @inheritdoc */
26
30
  protected get wrapperComponent(): any {
27
31
  return BottomSheet.WrapperComponent
28
32
  }
29
33
 
34
+ /** Closes all open BottomSheet instances. */
35
+ static async closeAll() {
36
+ const registries = BottomSheet.registry.getAll()
37
+ await Promise.all(registries.map(instance => instance.close()))
38
+ }
39
+
40
+ /** Name of the ref method to call imperatively when opening the sheet (set by the consuming app). */
30
41
  static openKeyMethod: Keyof<BottomSheetRef>
31
42
 
43
+ /** Name of the ref method to call imperatively when closing the sheet (set by the consuming app). */
32
44
  static closeKeyMethod: Keyof<BottomSheetRef>
33
45
 
46
+ /** Returns default config: `rendersWhenHidden=true` and `resetParamsOnClose=false`. */
34
47
  protected getDefaultConfig(): Partial<PortalConfig<Params, Metadata>> {
35
48
  return {
36
49
  rendersWhenHidden: true,
@@ -38,22 +51,26 @@ export class BottomSheet<Params = {}, Result = {}, Metadata = {}> extends Portal
38
51
  } as Partial<PortalConfig<Params, Metadata>>
39
52
  }
40
53
 
54
+ /** Calls the native open method on the ref via `openKeyMethod`, if configured. */
41
55
  protected openBottomSheet() {
42
56
  if (!!BottomSheet.openKeyMethod && !!this.ref?.current) {
43
- this.ref?.current?.[BottomSheet.openKeyMethod as string]?.()
57
+ (this.ref?.current as Record<string, (() => void) | undefined>)?.[BottomSheet.openKeyMethod as string]?.()
44
58
  }
45
59
  }
46
60
 
61
+ /** Calls the native close method on the ref via `closeKeyMethod`, if configured. */
47
62
  protected closeBottomSheet() {
48
63
  if (!!BottomSheet.closeKeyMethod && !!this.ref?.current) {
49
- this.ref?.current?.[BottomSheet.closeKeyMethod as string]?.()
64
+ (this.ref?.current as Record<string, (() => void) | undefined>)?.[BottomSheet.closeKeyMethod as string]?.()
50
65
  }
51
66
  }
52
67
 
68
+ /** @inheritdoc — also triggers the native open animation via `openBottomSheet()`. */
53
69
  protected handleOpen() {
54
70
  this.openBottomSheet()
55
71
  }
56
72
 
73
+ /** @inheritdoc — also triggers the native close animation via `closeBottomSheet()`. */
57
74
  protected handleClose() {
58
75
  this.closeBottomSheet()
59
76
  }
@@ -14,17 +14,27 @@ import { PortalConstructorParam, PortalWrapperProps } from '../types'
14
14
  export class Drawer<Params = {}, Result = {}, Metadata = {}> extends Portal<Params, Result, Metadata, DrawerRef, DrawerWrapperProps> {
15
15
  displayName = 'Drawer'
16
16
 
17
- static WrapperComponent: (props: PortalWrapperProps<DrawerWrapperProps, DrawerRef>) => ReactElement = () => null
17
+ /** Wrapper component used to render drawer UI. Must be assigned by the consuming app. */
18
+ static WrapperComponent: (props: PortalWrapperProps<DrawerWrapperProps, DrawerRef>) => ReactElement | null = () => null
18
19
 
20
+ /** Default transition duration in milliseconds for drawer open/close animations. */
19
21
  static DEFAULT_TRANSITION_DURATION = 200
20
22
 
23
+ /** @inheritdoc */
21
24
  protected get defaultTransitionDuration() {
22
25
  return Drawer.DEFAULT_TRANSITION_DURATION
23
26
  }
24
27
 
28
+ /** @inheritdoc */
25
29
  protected get wrapperComponent(): any {
26
30
  return Drawer.WrapperComponent
27
31
  }
32
+
33
+ /** Closes all open Drawer instances. */
34
+ static async closeAll() {
35
+ const registries = Drawer.registry.getAll()
36
+ await Promise.all(registries.map(instance => instance.close()))
37
+ }
28
38
  }
29
39
 
30
40
  /**
@@ -14,17 +14,27 @@ import { PortalConstructorParam, PortalWrapperProps } from '../types'
14
14
  export class Modal<Params = {}, Result = {}, Metadata = {}> extends Portal<Params, Result, Metadata, ModalRef, ModalWrapperProps> {
15
15
  displayName = 'Modal'
16
16
 
17
- static WrapperComponent: (props: PortalWrapperProps<ModalWrapperProps, ModalRef>) => ReactElement = () => null
17
+ /** Wrapper component used to render modal UI. Must be assigned by the consuming app. */
18
+ static WrapperComponent: (props: PortalWrapperProps<ModalWrapperProps, ModalRef>) => ReactElement | null = () => null
18
19
 
20
+ /** Default transition duration in milliseconds for modal open/close animations. */
19
21
  static DEFAULT_TRANSITION_DURATION = 200
20
22
 
23
+ /** @inheritdoc */
21
24
  protected get defaultTransitionDuration() {
22
25
  return Modal.DEFAULT_TRANSITION_DURATION
23
26
  }
24
27
 
28
+ /** @inheritdoc */
25
29
  protected get wrapperComponent(): any {
26
30
  return Modal.WrapperComponent
27
31
  }
32
+
33
+ /** Closes all open Modal instances. */
34
+ static async closeAll() {
35
+ const registries = Modal.registry.getAll()
36
+ await Promise.all(registries.map(instance => instance.close()))
37
+ }
28
38
  }
29
39
 
30
40
  /**
package/src/globals.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  /* eslint-disable @typescript-eslint/no-empty-interface */
2
2
 
3
+ /** Props for the bottom sheet wrapper component. Extend this interface in the consuming app. */
3
4
  export interface BottomSheetWrapperProps {}
4
5
 
6
+ /** Ref type for the bottom sheet component. Extend this interface to expose imperative methods. */
5
7
  export interface BottomSheetRef {}
6
8
 
9
+ /** Props for the modal wrapper component. Extend this interface in the consuming app. */
7
10
  export interface ModalWrapperProps {}
8
11
 
12
+ /** Ref type for the modal component. Extend this interface to expose imperative methods. */
9
13
  export interface ModalRef {}
10
14
 
15
+ /** Props for the drawer wrapper component. Extend this interface in the consuming app. */
11
16
  export interface DrawerWrapperProps {}
12
17
 
18
+ /** Ref type for the drawer component. Extend this interface to expose imperative methods. */
13
19
  export interface DrawerRef {}
@@ -11,6 +11,11 @@ import { PortalRequest } from './PortalRequest'
11
11
 
12
12
  type IAtom<T> = ReturnType<typeof atom<T>>
13
13
 
14
+ /**
15
+ * Nanostores atom holding the IDs of all currently registered portals in creation order.
16
+ * Drives `GlobalOutlet` re-renders when portals are added or removed (including ephemeral ones).
17
+ * Read-only outside of portal lifecycle code — mutate only via `Portal` constructor/`onVisibilityChanged`.
18
+ */
14
19
  export const registeredIds = atom<string[]>([])
15
20
 
16
21
  /**
@@ -28,9 +33,9 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
28
33
 
29
34
  id: string
30
35
 
31
- RenderContent: RenderPortalContent<Params, Result, RefType>
36
+ RenderContent!: RenderPortalContent<Params, Result, RefType>
32
37
 
33
- ref?: React.RefObject<RefType>
38
+ ref?: React.RefObject<RefType | null>
34
39
 
35
40
  requestHandler: PortalRequest<Params, Result>
36
41
 
@@ -38,7 +43,7 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
38
43
 
39
44
  _wrapperProps: IAtom<WrapperProps>
40
45
 
41
- _lazyWrapperProps: (params: Params) => WrapperProps
46
+ _lazyWrapperProps!: (params: Params) => WrapperProps
42
47
 
43
48
  static registry = new PortalRegistry<Portal<any, any, any>>()
44
49
 
@@ -56,7 +61,7 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
56
61
  return Portal.generateId
57
62
  }
58
63
 
59
- protected get defaultTransitionDuration() {
64
+ protected get defaultTransitionDuration(): number | null {
60
65
  return null
61
66
  }
62
67
 
@@ -74,15 +79,17 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
74
79
  return this
75
80
  }
76
81
 
82
+ /** Gets the resolve handler for the pending request. */
77
83
  get resolve() {
78
84
  return this.requestHandler.resolve
79
85
  }
80
86
 
87
+ /** Gets the reject handler for the pending request. */
81
88
  get reject() {
82
89
  return this.requestHandler.reject
83
90
  }
84
91
 
85
- private onVisibilityChanged(visible: boolean, wasVisible: boolean) {
92
+ private onVisibilityChanged(visible: boolean, wasVisible?: boolean) {
86
93
  if (this._config.independent) {
87
94
  return
88
95
  }
@@ -92,19 +99,30 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
92
99
  } else {
93
100
  if (wasVisible && !visible) {
94
101
  if (this.hasPendingRequest) {
95
- this.requestHandler.resolve?.(null)
102
+ this.requestHandler.resolve?.(undefined)
96
103
  this.requestHandler.clearRequest()
97
104
  }
105
+
106
+ if (this._config.ephemeral) {
107
+ this.registry.unregister(this.id)
108
+ registeredIds.set(registeredIds.get().filter(id => id !== this.id))
109
+ return
110
+ }
98
111
  }
99
112
 
100
113
  this.registry.remove(this.id)
101
114
  }
102
115
  }
103
116
 
117
+ /** Gets the z-index position of the portal in the registry stack. */
104
118
  get stackIndex() {
105
119
  return this.registry.getStackIndex(this.id)
106
120
  }
107
121
 
122
+ /**
123
+ * Creates a new portal instance.
124
+ * @param idOrConfig - Portal ID string or configuration object with optional id field
125
+ */
108
126
  constructor(idOrConfig?: PortalConstructorParam<Params, Metadata>) {
109
127
  super()
110
128
 
@@ -124,10 +142,10 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
124
142
  }
125
143
 
126
144
  this.initializeState({
127
- initialParams: this._config.initialParams,
128
- startsOpen: this._config.startsOpen,
145
+ initialParams: this._config.initialParams!,
146
+ startsOpen: this._config.startsOpen!,
129
147
  resetParamsOnClose: this._config.resetParamsOnClose,
130
- transitionDuration: this._config.transitionDuration,
148
+ transitionDuration: this._config.transitionDuration!,
131
149
  })
132
150
 
133
151
  this.id = id ?? this.idGenerator()
@@ -157,6 +175,7 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
157
175
  this.resetParams = this.resetParams.bind(this)
158
176
  this.getParams = this.getParams.bind(this)
159
177
  this.request = this.request.bind(this)
178
+ this.assertInitialized = this.assertInitialized.bind(this)
160
179
  }
161
180
 
162
181
  /**
@@ -213,12 +232,16 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
213
232
  * @returns Object with visible and params state
214
233
  */
215
234
  useState() {
216
- const visible = useStore(this.visible)
217
- const params = useStore(this.params)
235
+
236
+ const { visible: visibleStore, params: paramsStore } = this.assertInitialized()
237
+
238
+ const visible = useStore(visibleStore)
239
+ const params = useStore(paramsStore)
218
240
 
219
241
  return { visible, params }
220
242
  }
221
243
 
244
+ /** Whether there is a pending request awaiting resolution. */
222
245
  get hasPendingRequest() {
223
246
  return this.requestHandler.hasPendingRequest
224
247
  }
@@ -228,11 +251,22 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
228
251
  * @param props - Combined params and wrapper props
229
252
  * @returns JSX element with portal content wrapped in wrapper component
230
253
  */
231
- Component(props?: Params & { portalProps?: WrapperProps }): JSX.Element {
254
+ Component(props?: Params & { portalProps?: WrapperProps }): JSX.Element | null {
232
255
  const { visible, params } = this.useState()
233
256
  const wrapperProps = useStore(this._wrapperProps)
234
257
  const { portalProps = {}, ...propParams } = props || {}
235
258
 
259
+ const [mounted, setMounted] = React.useState(visible)
260
+
261
+ React.useEffect(() => {
262
+ if (visible) {
263
+ setMounted(true)
264
+ return
265
+ }
266
+ const timer = setTimeout(() => setMounted(false), this._config?.transitionDuration ?? 0)
267
+ return () => clearTimeout(timer)
268
+ }, [visible])
269
+
236
270
  const Content = this.RenderContent
237
271
 
238
272
  if (!Content) {
@@ -240,7 +274,7 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
240
274
  return null
241
275
  }
242
276
 
243
- if (!visible && !this._config?.rendersWhenHidden) return null
277
+ if (!visible && !mounted && !this._config?.rendersWhenHidden) return null
244
278
 
245
279
  const request = this.requestHandler.getRequestHandlers()
246
280
 
@@ -261,6 +295,7 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
261
295
  zIndex={this.stackIndex}
262
296
  ref={this.ref}
263
297
  >
298
+ {/* @ts-ignore — Content is a call signature, not a component type; LibraryManagedAttributes can't verify the spread */}
264
299
  <Content
265
300
  visible={visible}
266
301
  toggle={this.toggle}
@@ -269,7 +304,7 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
269
304
  setParams={this.setParams}
270
305
  {...params}
271
306
  {...(propParams ?? {})}
272
- request={this.hasPendingRequest ? request : null}
307
+ request={this.hasPendingRequest ? request : undefined}
273
308
  nextOrToggle={this.toggle}
274
309
  previousOrToggle={this.toggle}
275
310
  ref={this.ref}
@@ -287,6 +322,19 @@ export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, Wrap
287
322
  return this.requestHandler.request(params, force)
288
323
  }
289
324
 
325
+ /**
326
+ * Resets all registered portals to their closed initial state.
327
+ * Useful in test afterEach hooks to prevent state leaking between tests.
328
+ */
329
+ static resetAll() {
330
+ Portal.registry.getAll().forEach(portal => {
331
+ portal.visible?.set(false)
332
+ if (portal._initialParams !== undefined) {
333
+ portal.params?.set(portal._initialParams as any)
334
+ }
335
+ })
336
+ }
337
+
290
338
  /**
291
339
  * Global outlet component that renders all non-independent portals.
292
340
  * Place this once in your app root.
@@ -6,9 +6,9 @@
6
6
  * @template Result - Type of result returned when request resolves
7
7
  */
8
8
  export class PortalRequest<Params = {}, Result = {}> {
9
- resolve: (result: Result) => void
9
+ resolve?: (result: Result | undefined) => void
10
10
 
11
- reject: (reason: unknown) => void
11
+ reject?: (reason: unknown) => void
12
12
 
13
13
  private onOpen: (params?: Params) => Promise<void>
14
14
 
@@ -41,13 +41,13 @@ export class PortalRequest<Params = {}, Result = {}> {
41
41
  * @param force - Force new request even if one is pending
42
42
  * @returns Promise that resolves with portal result
43
43
  */
44
- request(params?: Params, force = false): Promise<Result> {
44
+ request(params?: Params, force = false): Promise<Result|undefined> {
45
45
  if (this.hasPendingRequest && !force) {
46
46
  return Promise.reject(new Error('This portal already has a pending request'))
47
47
  }
48
48
 
49
- return new Promise<Result>((resolve, reject) => {
50
- const onResolve = (result: Result) => {
49
+ return new Promise<Result|undefined>((resolve, reject) => {
50
+ const onResolve = (result: Result|undefined) => {
51
51
  resolve(result)
52
52
  this.onClose()
53
53
  this.clearRequest()