@codeleap/portals 6.3.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/package.json +32 -0
- package/package.json.bak +32 -0
- package/src/factors/alert.ts +109 -0
- package/src/factors/bottomSheet.ts +99 -0
- package/src/factors/drawer.ts +37 -0
- package/src/factors/modal.ts +37 -0
- package/src/globals.ts +13 -0
- package/src/index.ts +13 -0
- package/src/lib/Portal.tsx +300 -0
- package/src/lib/PortalRegistry.ts +91 -0
- package/src/lib/PortalRequest.ts +87 -0
- package/src/lib/PortalState.ts +173 -0
- package/src/tests/Portal.spec.tsx +229 -0
- package/src/types/index.ts +1 -0
- package/src/types/misc.ts +8 -0
- package/src/types/portal.ts +39 -0
- package/src/utils.ts +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codeleap/portals",
|
|
3
|
+
"version": "6.3.0",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/codeleap-uk/internal-libs-monorepo.git",
|
|
8
|
+
"type": "git",
|
|
9
|
+
"directory": "packages/portals"
|
|
10
|
+
},
|
|
11
|
+
"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",
|
|
16
|
+
"ts-node-dev": "1.1.8"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "echo 'No build needed'",
|
|
20
|
+
"lint": "eslint -c .eslintrc.js --fix \"./src/**/*.{ts,tsx,js,jsx}\"",
|
|
21
|
+
"run-sc": "tsnd --transpile-only"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@codeleap/types": "6.3.0",
|
|
25
|
+
"@codeleap/utils": "6.3.0",
|
|
26
|
+
"@codeleap/logger": "6.3.0",
|
|
27
|
+
"typescript": "5.5.2",
|
|
28
|
+
"react": "19.1.0",
|
|
29
|
+
"nanostores": "*",
|
|
30
|
+
"@nanostores/react": "*"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/package.json.bak
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codeleap/portals",
|
|
3
|
+
"version": "6.3.0",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/codeleap-uk/internal-libs-monorepo.git",
|
|
8
|
+
"type": "git",
|
|
9
|
+
"directory": "packages/portals"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@codeleap/types": "workspace:*",
|
|
13
|
+
"@codeleap/config": "workspace:*",
|
|
14
|
+
"@codeleap/utils": "workspace:*",
|
|
15
|
+
"@codeleap/logger": "workspace:*",
|
|
16
|
+
"ts-node-dev": "1.1.8"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "echo 'No build needed'",
|
|
20
|
+
"lint": "eslint -c .eslintrc.js --fix \"./src/**/*.{ts,tsx,js,jsx}\"",
|
|
21
|
+
"run-sc": "tsnd --transpile-only"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@codeleap/types": "workspace:*",
|
|
25
|
+
"@codeleap/utils": "workspace:*",
|
|
26
|
+
"@codeleap/logger": "workspace:*",
|
|
27
|
+
"typescript": "5.5.2",
|
|
28
|
+
"react": "19.1.0",
|
|
29
|
+
"nanostores": "*",
|
|
30
|
+
"@nanostores/react": "*"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AnyFunction } from '@codeleap/types'
|
|
2
|
+
import { Modal } from './modal'
|
|
3
|
+
|
|
4
|
+
export type AlertType = 'info' | 'error' | 'warn' | 'ask' | 'custom'
|
|
5
|
+
|
|
6
|
+
export type AlertOption = {
|
|
7
|
+
text: string
|
|
8
|
+
onPress?: AnyFunction
|
|
9
|
+
style?: 'destructive' | 'outline' | 'cancel' | 'default' | 'minimal'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AlertOptions = {
|
|
13
|
+
title?: string
|
|
14
|
+
body?: string
|
|
15
|
+
options?: AlertOption[]
|
|
16
|
+
onDismiss?: AnyFunction
|
|
17
|
+
type?: AlertType
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Alert utility for displaying typed alert modals (info, error, warn, ask, custom).
|
|
22
|
+
* Provides convenient methods for common alert scenarios.
|
|
23
|
+
*/
|
|
24
|
+
export class Alert {
|
|
25
|
+
static modal: Modal<AlertOptions>
|
|
26
|
+
|
|
27
|
+
static openAlert(): Modal<AlertOptions> {
|
|
28
|
+
return Alert.modal
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private trigger(args: AlertOptions) {
|
|
32
|
+
Alert.openAlert().open(args)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Opens an alert with 'ask' type (for questions).
|
|
37
|
+
* @param args - Alert options (defaults title to 'Quick question')
|
|
38
|
+
*/
|
|
39
|
+
ask(args: AlertOptions) {
|
|
40
|
+
const { title = 'Quick question', ...rest } = args
|
|
41
|
+
|
|
42
|
+
this.trigger({
|
|
43
|
+
...rest,
|
|
44
|
+
title,
|
|
45
|
+
type: 'ask',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Opens an alert with 'error' type.
|
|
51
|
+
* @param args - Alert options (defaults title to 'Whoops!' and body to 'Something went wrong')
|
|
52
|
+
*/
|
|
53
|
+
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
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
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
|
+
*/
|
|
68
|
+
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
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
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
|
+
*/
|
|
83
|
+
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
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Opens a custom alert with user-defined options and type.
|
|
96
|
+
* @param options - Custom alert options and additional properties
|
|
97
|
+
*/
|
|
98
|
+
custom<T>(options: Partial<AlertOptions> & T) {
|
|
99
|
+
this.trigger({
|
|
100
|
+
...(options as any),
|
|
101
|
+
type: 'custom',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Global alert instance for showing alerts throughout the app.
|
|
108
|
+
*/
|
|
109
|
+
export const alert = new Alert()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Portal } from '../lib/Portal'
|
|
2
|
+
import { PortalConstructorParam, PortalConfig, PortalWrapperProps } from '../types'
|
|
3
|
+
import { BottomSheetRef, BottomSheetWrapperProps } from '../globals'
|
|
4
|
+
import { Keyof } from '@codeleap/types'
|
|
5
|
+
import { ReactElement } from 'react'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bottom sheet portal for bottom-sliding panels.
|
|
9
|
+
* Extends Portal with bottom sheet-specific wrapper, configuration and ref methods.
|
|
10
|
+
*
|
|
11
|
+
* @template Params - Custom bottom sheet parameters
|
|
12
|
+
* @template Result - Type of result returned by request
|
|
13
|
+
* @template Metadata - Additional configuration metadata
|
|
14
|
+
*/
|
|
15
|
+
export class BottomSheet<Params = {}, Result = {}, Metadata = {}> extends Portal<Params, Result, Metadata, BottomSheetRef, BottomSheetWrapperProps> {
|
|
16
|
+
displayName = 'BottomSheet'
|
|
17
|
+
|
|
18
|
+
static WrapperComponent: (props: PortalWrapperProps<BottomSheetWrapperProps, BottomSheetRef>) => ReactElement = () => null
|
|
19
|
+
|
|
20
|
+
static DEFAULT_TRANSITION_DURATION = 200
|
|
21
|
+
|
|
22
|
+
protected get defaultTransitionDuration() {
|
|
23
|
+
return BottomSheet.DEFAULT_TRANSITION_DURATION
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected get wrapperComponent(): any {
|
|
27
|
+
return BottomSheet.WrapperComponent
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static openKeyMethod: Keyof<BottomSheetRef>
|
|
31
|
+
|
|
32
|
+
static closeKeyMethod: Keyof<BottomSheetRef>
|
|
33
|
+
|
|
34
|
+
protected getDefaultConfig(): Partial<PortalConfig<Params, Metadata>> {
|
|
35
|
+
return {
|
|
36
|
+
rendersWhenHidden: true,
|
|
37
|
+
resetParamsOnClose: false,
|
|
38
|
+
} as Partial<PortalConfig<Params, Metadata>>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected openBottomSheet() {
|
|
42
|
+
if (!!BottomSheet.openKeyMethod && !!this.ref?.current) {
|
|
43
|
+
this.ref?.current?.[BottomSheet.openKeyMethod as string]?.()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
protected closeBottomSheet() {
|
|
48
|
+
if (!!BottomSheet.closeKeyMethod && !!this.ref?.current) {
|
|
49
|
+
this.ref?.current?.[BottomSheet.closeKeyMethod as string]?.()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected handleOpen() {
|
|
54
|
+
this.openBottomSheet()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected handleClose() {
|
|
58
|
+
this.closeBottomSheet()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Registers a callback to be called when bottom sheet opens.
|
|
63
|
+
* Overrides parent to ensure native open method is called.
|
|
64
|
+
* @param callback - Function to execute on open
|
|
65
|
+
* @returns The bottom sheet instance for chaining
|
|
66
|
+
*/
|
|
67
|
+
onOpen(callback: (portal: Portal<Params, Result, Metadata, BottomSheetRef, BottomSheetWrapperProps>) => void) {
|
|
68
|
+
this.handleOpen = () => {
|
|
69
|
+
this.openBottomSheet()
|
|
70
|
+
callback(this)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Registers a callback to be called when bottom sheet closes.
|
|
78
|
+
* Overrides parent to ensure native close method is called.
|
|
79
|
+
* @param callback - Function to execute on close
|
|
80
|
+
* @returns The bottom sheet instance for chaining
|
|
81
|
+
*/
|
|
82
|
+
onClose(callback: (portal: Portal<Params, Result, Metadata, BottomSheetRef, BottomSheetWrapperProps>) => void) {
|
|
83
|
+
this.handleClose = () => {
|
|
84
|
+
this.closeBottomSheet()
|
|
85
|
+
callback(this)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return this
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Factory function to create a new BottomSheet instance.
|
|
94
|
+
* @param idOrConfig - BottomSheet ID or configuration object
|
|
95
|
+
* @returns New BottomSheet instance
|
|
96
|
+
*/
|
|
97
|
+
export function bottomSheet<Params = {}, Result = {}, Metadata = {}>(idOrConfig?: PortalConstructorParam<Params, Metadata>) {
|
|
98
|
+
return new BottomSheet<Params, Result, Metadata>(idOrConfig)
|
|
99
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ReactElement } from 'react'
|
|
2
|
+
import { DrawerRef, DrawerWrapperProps } from '../globals'
|
|
3
|
+
import { Portal } from '../lib/Portal'
|
|
4
|
+
import { PortalConstructorParam, PortalWrapperProps } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drawer portal for side-sliding panels.
|
|
8
|
+
* Extends Portal with drawer-specific wrapper and configuration.
|
|
9
|
+
*
|
|
10
|
+
* @template Params - Custom drawer parameters
|
|
11
|
+
* @template Result - Type of result returned by request
|
|
12
|
+
* @template Metadata - Additional configuration metadata
|
|
13
|
+
*/
|
|
14
|
+
export class Drawer<Params = {}, Result = {}, Metadata = {}> extends Portal<Params, Result, Metadata, DrawerRef, DrawerWrapperProps> {
|
|
15
|
+
displayName = 'Drawer'
|
|
16
|
+
|
|
17
|
+
static WrapperComponent: (props: PortalWrapperProps<DrawerWrapperProps, DrawerRef>) => ReactElement = () => null
|
|
18
|
+
|
|
19
|
+
static DEFAULT_TRANSITION_DURATION = 200
|
|
20
|
+
|
|
21
|
+
protected get defaultTransitionDuration() {
|
|
22
|
+
return Drawer.DEFAULT_TRANSITION_DURATION
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected get wrapperComponent(): any {
|
|
26
|
+
return Drawer.WrapperComponent
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Factory function to create a new Drawer instance.
|
|
32
|
+
* @param idOrConfig - Drawer ID or configuration object
|
|
33
|
+
* @returns New Drawer instance
|
|
34
|
+
*/
|
|
35
|
+
export function drawer<Params = {}, Result = {}, Metadata = {}>(idOrConfig?: PortalConstructorParam<Params, Metadata>) {
|
|
36
|
+
return new Drawer<Params, Result, Metadata>(idOrConfig)
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ReactElement } from 'react'
|
|
2
|
+
import { ModalRef, ModalWrapperProps } from '../globals'
|
|
3
|
+
import { Portal } from '../lib/Portal'
|
|
4
|
+
import { PortalConstructorParam, PortalWrapperProps } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Modal portal for centered overlay dialogs.
|
|
8
|
+
* Extends Portal with modal-specific wrapper and configuration.
|
|
9
|
+
*
|
|
10
|
+
* @template Params - Custom modal parameters
|
|
11
|
+
* @template Result - Type of result returned by request
|
|
12
|
+
* @template Metadata - Additional configuration metadata
|
|
13
|
+
*/
|
|
14
|
+
export class Modal<Params = {}, Result = {}, Metadata = {}> extends Portal<Params, Result, Metadata, ModalRef, ModalWrapperProps> {
|
|
15
|
+
displayName = 'Modal'
|
|
16
|
+
|
|
17
|
+
static WrapperComponent: (props: PortalWrapperProps<ModalWrapperProps, ModalRef>) => ReactElement = () => null
|
|
18
|
+
|
|
19
|
+
static DEFAULT_TRANSITION_DURATION = 200
|
|
20
|
+
|
|
21
|
+
protected get defaultTransitionDuration() {
|
|
22
|
+
return Modal.DEFAULT_TRANSITION_DURATION
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected get wrapperComponent(): any {
|
|
26
|
+
return Modal.WrapperComponent
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Factory function to create a new Modal instance.
|
|
32
|
+
* @param idOrConfig - Modal ID or configuration object
|
|
33
|
+
* @returns New Modal instance
|
|
34
|
+
*/
|
|
35
|
+
export function modal<Params = {}, Result = {}, Metadata = {}>(idOrConfig?: PortalConstructorParam<Params, Metadata>) {
|
|
36
|
+
return new Modal<Params, Result, Metadata>(idOrConfig)
|
|
37
|
+
}
|
package/src/globals.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-interface */
|
|
2
|
+
|
|
3
|
+
export interface BottomSheetWrapperProps {}
|
|
4
|
+
|
|
5
|
+
export interface BottomSheetRef {}
|
|
6
|
+
|
|
7
|
+
export interface ModalWrapperProps {}
|
|
8
|
+
|
|
9
|
+
export interface ModalRef {}
|
|
10
|
+
|
|
11
|
+
export interface DrawerWrapperProps {}
|
|
12
|
+
|
|
13
|
+
export interface DrawerRef {}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './lib/Portal'
|
|
2
|
+
export * from './lib/PortalRegistry'
|
|
3
|
+
export * from './lib/PortalState'
|
|
4
|
+
export * from './lib/PortalRequest'
|
|
5
|
+
export * from './types/misc'
|
|
6
|
+
export * from './types/portal'
|
|
7
|
+
export * from './utils'
|
|
8
|
+
// export * from './lib/modalFlow'
|
|
9
|
+
export * from './factors/alert'
|
|
10
|
+
export * from './factors/modal'
|
|
11
|
+
export * from './factors/bottomSheet'
|
|
12
|
+
export * from './factors/drawer'
|
|
13
|
+
export * from './globals'
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import React, { JSX, useLayoutEffect } from 'react'
|
|
2
|
+
import { atom } from 'nanostores'
|
|
3
|
+
import { useStore } from '@nanostores/react'
|
|
4
|
+
import { AnyRecord, TypeGuards } from '@codeleap/types'
|
|
5
|
+
import { logger } from '@codeleap/logger'
|
|
6
|
+
import { randomId } from '../utils'
|
|
7
|
+
import { PortalConfig, PortalConstructorParam, RenderPortalContent } from '../types'
|
|
8
|
+
import { PortalRegistry } from './PortalRegistry'
|
|
9
|
+
import { PortalState } from './PortalState'
|
|
10
|
+
import { PortalRequest } from './PortalRequest'
|
|
11
|
+
|
|
12
|
+
type IAtom<T> = ReturnType<typeof atom<T>>
|
|
13
|
+
|
|
14
|
+
export const registeredIds = atom<string[]>([])
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base class for creating portals (modal, drawer, bottom sheet, etc).
|
|
18
|
+
* Manages state, visibility, params and lifecycle of floating components.
|
|
19
|
+
*
|
|
20
|
+
* @template Params - Custom portal parameters
|
|
21
|
+
* @template Result - Type of result returned by request
|
|
22
|
+
* @template Metadata - Additional configuration metadata
|
|
23
|
+
* @template RefType - Type of wrapper component ref
|
|
24
|
+
* @template WrapperProps - Props for wrapper component
|
|
25
|
+
*/
|
|
26
|
+
export class Portal<Params = {}, Result = {}, Metadata = {}, RefType = any, WrapperProps = AnyRecord> extends PortalState<Params> {
|
|
27
|
+
displayName = 'Portal'
|
|
28
|
+
|
|
29
|
+
id: string
|
|
30
|
+
|
|
31
|
+
RenderContent: RenderPortalContent<Params, Result, RefType>
|
|
32
|
+
|
|
33
|
+
ref?: React.RefObject<RefType>
|
|
34
|
+
|
|
35
|
+
requestHandler: PortalRequest<Params, Result>
|
|
36
|
+
|
|
37
|
+
_config: PortalConfig<Params, Metadata>
|
|
38
|
+
|
|
39
|
+
_wrapperProps: IAtom<WrapperProps>
|
|
40
|
+
|
|
41
|
+
_lazyWrapperProps: (params: Params) => WrapperProps
|
|
42
|
+
|
|
43
|
+
static registry = new PortalRegistry<Portal<any, any, any>>()
|
|
44
|
+
|
|
45
|
+
static generateId = randomId
|
|
46
|
+
|
|
47
|
+
protected getDefaultConfig(): Partial<PortalConfig<Params, Metadata>> {
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected get registry(): PortalRegistry<any> {
|
|
52
|
+
return Portal.registry
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
protected get idGenerator(): () => string {
|
|
56
|
+
return Portal.generateId
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected get defaultTransitionDuration() {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected get wrapperComponent(): any {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sets the render function for portal content.
|
|
69
|
+
* @param render - Function that renders the portal content
|
|
70
|
+
* @returns The portal instance for chaining
|
|
71
|
+
*/
|
|
72
|
+
content(render: RenderPortalContent<Params, Result, RefType>) {
|
|
73
|
+
this.RenderContent = render
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get resolve() {
|
|
78
|
+
return this.requestHandler.resolve
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get reject() {
|
|
82
|
+
return this.requestHandler.reject
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private onVisibilityChanged(visible: boolean, wasVisible: boolean) {
|
|
86
|
+
if (this._config.independent) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (visible) {
|
|
91
|
+
this.registry.push(this.id)
|
|
92
|
+
} else {
|
|
93
|
+
if (wasVisible && !visible) {
|
|
94
|
+
if (this.hasPendingRequest) {
|
|
95
|
+
this.requestHandler.resolve?.(null)
|
|
96
|
+
this.requestHandler.clearRequest()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.registry.remove(this.id)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get stackIndex() {
|
|
105
|
+
return this.registry.getStackIndex(this.id)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
constructor(idOrConfig?: PortalConstructorParam<Params, Metadata>) {
|
|
109
|
+
super()
|
|
110
|
+
|
|
111
|
+
const id = TypeGuards.isString(idOrConfig) ? idOrConfig : idOrConfig?.id
|
|
112
|
+
const config = TypeGuards.isObject(idOrConfig) ? idOrConfig : {}
|
|
113
|
+
|
|
114
|
+
this._config = {
|
|
115
|
+
initialParams: {} as Params,
|
|
116
|
+
startsOpen: false,
|
|
117
|
+
independent: false,
|
|
118
|
+
rendersWhenHidden: false,
|
|
119
|
+
metadata: {} as Metadata,
|
|
120
|
+
resetParamsOnClose: true,
|
|
121
|
+
transitionDuration: (this.constructor as any).DEFAULT_TRANSITION_DURATION,
|
|
122
|
+
...this.getDefaultConfig(),
|
|
123
|
+
...config,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.initializeState({
|
|
127
|
+
initialParams: this._config.initialParams,
|
|
128
|
+
startsOpen: this._config.startsOpen,
|
|
129
|
+
resetParamsOnClose: this._config.resetParamsOnClose,
|
|
130
|
+
transitionDuration: this._config.transitionDuration,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
this.id = id ?? this.idGenerator()
|
|
134
|
+
|
|
135
|
+
this.requestHandler = new PortalRequest<Params, Result>(
|
|
136
|
+
this.open.bind(this),
|
|
137
|
+
this.close.bind(this),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
this._wrapperProps = atom({})
|
|
141
|
+
this.ref = React.createRef<RefType>()
|
|
142
|
+
|
|
143
|
+
this.registry.register(this.id, this)
|
|
144
|
+
registeredIds.set([...registeredIds.get(), this.id])
|
|
145
|
+
|
|
146
|
+
this.subscribe((visible, wasVisible) => {
|
|
147
|
+
this.onVisibilityChanged(visible, wasVisible)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
this.Component = this.Component.bind(this)
|
|
151
|
+
this.useState = this.useState.bind(this)
|
|
152
|
+
this.useProps = this.useProps.bind(this)
|
|
153
|
+
this.toggle = this.toggle.bind(this)
|
|
154
|
+
this.open = this.open.bind(this)
|
|
155
|
+
this.close = this.close.bind(this)
|
|
156
|
+
this.setParams = this.setParams.bind(this)
|
|
157
|
+
this.resetParams = this.resetParams.bind(this)
|
|
158
|
+
this.getParams = this.getParams.bind(this)
|
|
159
|
+
this.request = this.request.bind(this)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Registers a callback to be called when portal closes.
|
|
164
|
+
* @param callback - Function to execute on close
|
|
165
|
+
* @returns The portal instance for chaining
|
|
166
|
+
*/
|
|
167
|
+
onClose(callback: (portal: Portal<Params, Result, Metadata, RefType, WrapperProps>) => void) {
|
|
168
|
+
this.handleClose = () => callback(this)
|
|
169
|
+
return this
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Registers a callback to be called when portal opens.
|
|
174
|
+
* @param callback - Function to execute on open
|
|
175
|
+
* @returns The portal instance for chaining
|
|
176
|
+
*/
|
|
177
|
+
onOpen(callback: (portal: Portal<Params, Result, Metadata, RefType, WrapperProps>) => void) {
|
|
178
|
+
this.handleOpen = () => callback(this)
|
|
179
|
+
return this
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Sets wrapper component props. Can be static or a function based on params.
|
|
184
|
+
* @param props - Props object or function that returns props
|
|
185
|
+
* @returns The portal instance for chaining
|
|
186
|
+
*/
|
|
187
|
+
props(props: WrapperProps | ((params: Params) => WrapperProps)) {
|
|
188
|
+
if (TypeGuards.isFunction(props)) {
|
|
189
|
+
this._lazyWrapperProps = props
|
|
190
|
+
} else {
|
|
191
|
+
this._wrapperProps.set(props)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return this
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* React hook to update wrapper props with dependency tracking.
|
|
199
|
+
* @param props - Props to merge with existing wrapper props
|
|
200
|
+
* @param deps - Dependency array for effect
|
|
201
|
+
*/
|
|
202
|
+
useProps(props: WrapperProps, deps: React.DependencyList = []) {
|
|
203
|
+
useLayoutEffect(() => {
|
|
204
|
+
this._wrapperProps.set({
|
|
205
|
+
...this._wrapperProps.get(),
|
|
206
|
+
...props,
|
|
207
|
+
})
|
|
208
|
+
}, deps)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* React hook to access portal state (visibility and params).
|
|
213
|
+
* @returns Object with visible and params state
|
|
214
|
+
*/
|
|
215
|
+
useState() {
|
|
216
|
+
const visible = useStore(this.visible)
|
|
217
|
+
const params = useStore(this.params)
|
|
218
|
+
|
|
219
|
+
return { visible, params }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
get hasPendingRequest() {
|
|
223
|
+
return this.requestHandler.hasPendingRequest
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Renders the portal component. Use this as a React component.
|
|
228
|
+
* @param props - Combined params and wrapper props
|
|
229
|
+
* @returns JSX element with portal content wrapped in wrapper component
|
|
230
|
+
*/
|
|
231
|
+
Component(props?: Params & { portalProps?: WrapperProps }): JSX.Element {
|
|
232
|
+
const { visible, params } = this.useState()
|
|
233
|
+
const wrapperProps = useStore(this._wrapperProps)
|
|
234
|
+
const { portalProps = {}, ...propParams } = props || {}
|
|
235
|
+
|
|
236
|
+
const Content = this.RenderContent
|
|
237
|
+
|
|
238
|
+
if (!Content) {
|
|
239
|
+
logger.warn(`${this.displayName} ${this.id} has no content. Did you forget to call .content()?`)
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!visible && !this._config?.rendersWhenHidden) return null
|
|
244
|
+
|
|
245
|
+
const request = this.requestHandler.getRequestHandlers()
|
|
246
|
+
|
|
247
|
+
const lazyWrapperProps = this?._lazyWrapperProps ? this._lazyWrapperProps(params) : {}
|
|
248
|
+
|
|
249
|
+
const WrapperComponent = this.wrapperComponent
|
|
250
|
+
|
|
251
|
+
return <WrapperComponent
|
|
252
|
+
{...this._config}
|
|
253
|
+
{...this._wrapperProps}
|
|
254
|
+
{...portalProps}
|
|
255
|
+
{...wrapperProps}
|
|
256
|
+
{...lazyWrapperProps}
|
|
257
|
+
close={this.close}
|
|
258
|
+
open={this.open}
|
|
259
|
+
visible={visible}
|
|
260
|
+
toggle={this.toggle}
|
|
261
|
+
zIndex={this.stackIndex}
|
|
262
|
+
ref={this.ref}
|
|
263
|
+
>
|
|
264
|
+
<Content
|
|
265
|
+
visible={visible}
|
|
266
|
+
toggle={this.toggle}
|
|
267
|
+
close={this.close}
|
|
268
|
+
open={this.open}
|
|
269
|
+
setParams={this.setParams}
|
|
270
|
+
{...params}
|
|
271
|
+
{...(propParams ?? {})}
|
|
272
|
+
request={this.hasPendingRequest ? request : null}
|
|
273
|
+
nextOrToggle={this.toggle}
|
|
274
|
+
previousOrToggle={this.toggle}
|
|
275
|
+
ref={this.ref}
|
|
276
|
+
/>
|
|
277
|
+
</WrapperComponent>
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Creates a request promise that resolves when portal is closed with result.
|
|
282
|
+
* @param params - Parameters to pass to the portal
|
|
283
|
+
* @param force - Force new request even if one is pending
|
|
284
|
+
* @returns Promise that resolves with portal result
|
|
285
|
+
*/
|
|
286
|
+
request(params?: Params, force = false) {
|
|
287
|
+
return this.requestHandler.request(params, force)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Global outlet component that renders all non-independent portals.
|
|
292
|
+
* Place this once in your app root.
|
|
293
|
+
* @returns JSX element rendering all registered portals
|
|
294
|
+
*/
|
|
295
|
+
static GlobalOutlet() {
|
|
296
|
+
useStore(registeredIds)
|
|
297
|
+
const portals = Portal.registry.filter(p => !p._config?.independent)
|
|
298
|
+
return <>{portals.map(portal => <portal.Component key={portal.id} />)}</>
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for managing portal instances and their stacking order.
|
|
3
|
+
* Maintains a registry of portals by ID and tracks their z-index stack.
|
|
4
|
+
*
|
|
5
|
+
* @template T - Type of portal instances to manage
|
|
6
|
+
*/
|
|
7
|
+
export class PortalRegistry<T> {
|
|
8
|
+
private registry: Record<string, T> = {}
|
|
9
|
+
|
|
10
|
+
private stack: string[] = []
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Registers a portal instance with an ID.
|
|
14
|
+
* @param id - Unique identifier for the portal
|
|
15
|
+
* @param instance - Portal instance to register
|
|
16
|
+
*/
|
|
17
|
+
register(id: string, instance: T) {
|
|
18
|
+
this.registry[id] = instance
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Unregisters a portal and removes it from the stack.
|
|
23
|
+
* @param id - Portal ID to unregister
|
|
24
|
+
*/
|
|
25
|
+
unregister(id: string) {
|
|
26
|
+
delete this.registry[id]
|
|
27
|
+
this.remove(id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Retrieves a portal instance by ID.
|
|
32
|
+
* @param id - Portal ID to retrieve
|
|
33
|
+
* @returns Portal instance or undefined
|
|
34
|
+
*/
|
|
35
|
+
getInstance(id: string): T {
|
|
36
|
+
return this.registry[id]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Adds a portal ID to the top of the stack.
|
|
41
|
+
* @param id - Portal ID to push
|
|
42
|
+
*/
|
|
43
|
+
push(id: string) {
|
|
44
|
+
this.stack.push(id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Removes a portal ID from the stack and all portals above it.
|
|
49
|
+
* @param id - Portal ID to remove
|
|
50
|
+
*/
|
|
51
|
+
remove(id: string) {
|
|
52
|
+
const index = this.stack.indexOf(id)
|
|
53
|
+
if (index > -1) {
|
|
54
|
+
this.stack = this.stack.slice(0, index)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets the stack index (z-index position) of a portal.
|
|
60
|
+
* @param id - Portal ID to find
|
|
61
|
+
* @returns Stack index or -1 if not found
|
|
62
|
+
*/
|
|
63
|
+
getStackIndex(id: string): number {
|
|
64
|
+
return this.stack.indexOf(id)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Retrieves all registered portal instances.
|
|
69
|
+
* @returns Array of all portal instances
|
|
70
|
+
*/
|
|
71
|
+
getAll(): T[] {
|
|
72
|
+
return Object.values(this.registry)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Filters portal instances by predicate function.
|
|
77
|
+
* @param predicate - Function to test each instance
|
|
78
|
+
* @returns Filtered array of portal instances
|
|
79
|
+
*/
|
|
80
|
+
filter(predicate: (instance: T) => boolean): T[] {
|
|
81
|
+
return this.getAll().filter(predicate)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clears all portals from registry and stack.
|
|
86
|
+
*/
|
|
87
|
+
clear() {
|
|
88
|
+
this.registry = {}
|
|
89
|
+
this.stack = []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles promise-based portal requests with resolve/reject semantics.
|
|
3
|
+
* Manages the async flow of opening a portal and waiting for a result.
|
|
4
|
+
*
|
|
5
|
+
* @template Params - Parameters for opening the portal
|
|
6
|
+
* @template Result - Type of result returned when request resolves
|
|
7
|
+
*/
|
|
8
|
+
export class PortalRequest<Params = {}, Result = {}> {
|
|
9
|
+
resolve: (result: Result) => void
|
|
10
|
+
|
|
11
|
+
reject: (reason: unknown) => void
|
|
12
|
+
|
|
13
|
+
private onOpen: (params?: Params) => Promise<void>
|
|
14
|
+
|
|
15
|
+
private onClose: () => Promise<void>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a portal request handler.
|
|
19
|
+
* @param onOpen - Function to call when opening portal
|
|
20
|
+
* @param onClose - Function to call when closing portal
|
|
21
|
+
*/
|
|
22
|
+
constructor(
|
|
23
|
+
onOpen: (params?: Params) => Promise<void>,
|
|
24
|
+
onClose: () => Promise<void>,
|
|
25
|
+
) {
|
|
26
|
+
this.onOpen = onOpen
|
|
27
|
+
this.onClose = onClose
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Checks if there's a pending request waiting for resolution.
|
|
32
|
+
* @returns True if request is pending
|
|
33
|
+
*/
|
|
34
|
+
get hasPendingRequest() {
|
|
35
|
+
return !!this.resolve && !!this.reject
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a new request promise that opens the portal and waits for result.
|
|
40
|
+
* @param params - Parameters to pass when opening portal
|
|
41
|
+
* @param force - Force new request even if one is pending
|
|
42
|
+
* @returns Promise that resolves with portal result
|
|
43
|
+
*/
|
|
44
|
+
request(params?: Params, force = false): Promise<Result> {
|
|
45
|
+
if (this.hasPendingRequest && !force) {
|
|
46
|
+
return Promise.reject(new Error('This portal already has a pending request'))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new Promise<Result>((resolve, reject) => {
|
|
50
|
+
const onResolve = (result: Result) => {
|
|
51
|
+
resolve(result)
|
|
52
|
+
this.onClose()
|
|
53
|
+
this.clearRequest()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const onReject = (reason: unknown) => {
|
|
57
|
+
reject(reason)
|
|
58
|
+
this.onClose()
|
|
59
|
+
this.clearRequest()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.resolve = onResolve
|
|
63
|
+
this.reject = onReject
|
|
64
|
+
|
|
65
|
+
this.onOpen(params)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Clears the current request resolve/reject handlers.
|
|
71
|
+
*/
|
|
72
|
+
clearRequest() {
|
|
73
|
+
this.resolve = undefined
|
|
74
|
+
this.reject = undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Gets the current request handlers for resolve and reject.
|
|
79
|
+
* @returns Object with resolve and reject functions
|
|
80
|
+
*/
|
|
81
|
+
getRequestHandlers() {
|
|
82
|
+
return {
|
|
83
|
+
resolve: this.resolve,
|
|
84
|
+
reject: this.reject,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { atom, onMount, task } from 'nanostores'
|
|
2
|
+
import { TypeGuards } from '@codeleap/types'
|
|
3
|
+
import { awaitTransition } from '../utils'
|
|
4
|
+
|
|
5
|
+
type IAtom<T> = ReturnType<typeof atom<T>>
|
|
6
|
+
|
|
7
|
+
function initAtomWithPromise<T>(a: IAtom<T>, promise: Promise<T>) {
|
|
8
|
+
onMount(a, () => {
|
|
9
|
+
task(async () => {
|
|
10
|
+
a.set(await promise)
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type PortalStateConfig<Params> = {
|
|
16
|
+
initialParams: Params | (() => Promise<Params>)
|
|
17
|
+
startsOpen: boolean | (() => Promise<boolean>)
|
|
18
|
+
resetParamsOnClose?: boolean
|
|
19
|
+
transitionDuration: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Manages portal visibility and parameter state with nanostores.
|
|
24
|
+
* Handles opening, closing, toggling and parameter updates.
|
|
25
|
+
*
|
|
26
|
+
* @template Params - Type of parameters managed by the state
|
|
27
|
+
*/
|
|
28
|
+
export class PortalState<Params = {}> {
|
|
29
|
+
visible: IAtom<boolean>
|
|
30
|
+
|
|
31
|
+
params: IAtom<Params>
|
|
32
|
+
|
|
33
|
+
_initialParams: Params
|
|
34
|
+
|
|
35
|
+
private config: PortalStateConfig<Params>
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initializes the portal state with configuration.
|
|
39
|
+
* @param config - Configuration for initial state, visibility and params
|
|
40
|
+
*/
|
|
41
|
+
initializeState(config: PortalStateConfig<Params>) {
|
|
42
|
+
this.config = config
|
|
43
|
+
|
|
44
|
+
const initialVisible = TypeGuards.isBoolean(config.startsOpen) ? config.startsOpen : false
|
|
45
|
+
const initialParams = TypeGuards.isFunction(config.initialParams)
|
|
46
|
+
? {} as Params
|
|
47
|
+
: (config.initialParams ?? {}) as Params
|
|
48
|
+
|
|
49
|
+
this._initialParams = initialParams
|
|
50
|
+
|
|
51
|
+
this.visible = atom(initialVisible)
|
|
52
|
+
this.params = atom(initialParams)
|
|
53
|
+
|
|
54
|
+
if (TypeGuards.isFunction(config.startsOpen)) {
|
|
55
|
+
initAtomWithPromise(this.visible, config.startsOpen())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (TypeGuards.isFunction(config.initialParams)) {
|
|
59
|
+
initAtomWithPromise(this.params, config.initialParams().then(p => {
|
|
60
|
+
this._initialParams = p
|
|
61
|
+
return p
|
|
62
|
+
}))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get isVisible() {
|
|
67
|
+
return this.visible.get()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get currentParams() {
|
|
71
|
+
return this.params.get()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private awaitTransition(count = 1) {
|
|
75
|
+
return awaitTransition(count, this.config.transitionDuration)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
protected handleOpen() { }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Opens the portal with optional parameters.
|
|
82
|
+
* @param params - Parameters to merge with current params
|
|
83
|
+
*/
|
|
84
|
+
async open(params?: Params) {
|
|
85
|
+
if (this.visible.get()) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (params) {
|
|
90
|
+
this.params.set({
|
|
91
|
+
...this.params.get(),
|
|
92
|
+
...params,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.visible.set(true)
|
|
97
|
+
|
|
98
|
+
await this.awaitTransition()
|
|
99
|
+
|
|
100
|
+
this.handleOpen()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
protected handleClose() { }
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Closes the portal and optionally resets parameters.
|
|
107
|
+
*/
|
|
108
|
+
async close() {
|
|
109
|
+
if (!this.visible.get()) {
|
|
110
|
+
return this.awaitTransition()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.visible.set(false)
|
|
114
|
+
|
|
115
|
+
if (this.config.resetParamsOnClose) {
|
|
116
|
+
this.resetParams()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.handleClose()
|
|
120
|
+
|
|
121
|
+
await this.awaitTransition()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Toggles portal visibility (opens if closed, closes if open).
|
|
126
|
+
*/
|
|
127
|
+
toggle() {
|
|
128
|
+
if (this.visible.get()) {
|
|
129
|
+
return this.close()
|
|
130
|
+
} else {
|
|
131
|
+
return this.open()
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Updates portal parameters by merging with current params.
|
|
137
|
+
* @param next - Partial params or updater function
|
|
138
|
+
*/
|
|
139
|
+
setParams(next: Partial<Params> | ((prev: Params) => Partial<Params>)) {
|
|
140
|
+
const prev = this.params.get()
|
|
141
|
+
|
|
142
|
+
const patch = TypeGuards.isFunction(next) ? next(prev) : next
|
|
143
|
+
|
|
144
|
+
this.params.set({
|
|
145
|
+
...prev,
|
|
146
|
+
...patch,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resets parameters to initial values.
|
|
152
|
+
*/
|
|
153
|
+
resetParams() {
|
|
154
|
+
this.params.set(this._initialParams ?? {} as Params)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Gets current parameter values.
|
|
159
|
+
* @returns Current params object
|
|
160
|
+
*/
|
|
161
|
+
getParams() {
|
|
162
|
+
return this.params.get()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Subscribes to visibility changes.
|
|
167
|
+
* @param callback - Function called when visibility changes
|
|
168
|
+
* @returns Unsubscribe function
|
|
169
|
+
*/
|
|
170
|
+
subscribe(callback: (visible: boolean, wasVisible: boolean) => void) {
|
|
171
|
+
return this.visible.subscribe(callback)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { modal } from '../factors/modal'
|
|
5
|
+
|
|
6
|
+
describe('Portal', () => {
|
|
7
|
+
let testModal: ReturnType<typeof modal>
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
testModal = modal({ id: 'TEST_MODAL' })
|
|
11
|
+
.content((props) => {
|
|
12
|
+
const { visible, toggle, userId } = props
|
|
13
|
+
return (
|
|
14
|
+
<div>
|
|
15
|
+
<p data-testid='visibility'>{visible ? 'open' : 'closed'}</p>
|
|
16
|
+
<p data-testid='userId'>{userId}</p>
|
|
17
|
+
<button onClick={toggle}>Toggle</button>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('constructor', () => {
|
|
24
|
+
it('should create portal with default config', () => {
|
|
25
|
+
expect(testModal.id).toBe('TEST_MODAL')
|
|
26
|
+
expect(testModal.isVisible).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('should create portal with auto-generated ID', () => {
|
|
30
|
+
const autoModal = modal()
|
|
31
|
+
expect(autoModal.id).toBeDefined()
|
|
32
|
+
expect(typeof autoModal.id).toBe('string')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should create portal with initial params', () => {
|
|
36
|
+
const modalWithParams = modal<{ theme: string }>({
|
|
37
|
+
initialParams: { theme: 'dark' },
|
|
38
|
+
})
|
|
39
|
+
expect(modalWithParams.currentParams.theme).toBe('dark')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('open and close', () => {
|
|
44
|
+
it('should open portal', async () => {
|
|
45
|
+
expect(testModal.isVisible).toBe(false)
|
|
46
|
+
|
|
47
|
+
await testModal.open()
|
|
48
|
+
|
|
49
|
+
expect(testModal.isVisible).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should close portal', async () => {
|
|
53
|
+
await testModal.open()
|
|
54
|
+
expect(testModal.isVisible).toBe(true)
|
|
55
|
+
|
|
56
|
+
await testModal.close()
|
|
57
|
+
|
|
58
|
+
expect(testModal.isVisible).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should toggle portal', async () => {
|
|
62
|
+
expect(testModal.isVisible).toBe(false)
|
|
63
|
+
|
|
64
|
+
await testModal.toggle()
|
|
65
|
+
expect(testModal.isVisible).toBe(true)
|
|
66
|
+
|
|
67
|
+
await testModal.toggle()
|
|
68
|
+
expect(testModal.isVisible).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should open with params', async () => {
|
|
72
|
+
await testModal.open({ userId: '123' })
|
|
73
|
+
|
|
74
|
+
expect(testModal.currentParams.userId).toBe('123')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('params management', () => {
|
|
79
|
+
it('should set params', () => {
|
|
80
|
+
testModal.setParams({ userId: '456' })
|
|
81
|
+
|
|
82
|
+
expect(testModal.currentParams.userId).toBe('456')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should set params with updater function', () => {
|
|
86
|
+
testModal.setParams({ count: 0 })
|
|
87
|
+
|
|
88
|
+
testModal.setParams((prev) => ({ count: prev.count + 1 }))
|
|
89
|
+
|
|
90
|
+
expect(testModal.currentParams.count).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should reset params', () => {
|
|
94
|
+
const modalWithDefaults = modal({
|
|
95
|
+
initialParams: { theme: 'dark' },
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
modalWithDefaults.setParams({ theme: 'light' })
|
|
99
|
+
expect(modalWithDefaults.currentParams.theme).toBe('light')
|
|
100
|
+
|
|
101
|
+
modalWithDefaults.resetParams()
|
|
102
|
+
expect(modalWithDefaults.currentParams.theme).toBe('dark')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('useState hook', () => {
|
|
107
|
+
it('should return visible and params', () => {
|
|
108
|
+
const { result } = renderHook(() => testModal.useState())
|
|
109
|
+
|
|
110
|
+
expect(result.current.visible).toBe(false)
|
|
111
|
+
expect(result.current.params).toBeDefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should update when portal opens', async () => {
|
|
115
|
+
const { result } = renderHook(() => testModal.useState())
|
|
116
|
+
|
|
117
|
+
expect(result.current.visible).toBe(false)
|
|
118
|
+
|
|
119
|
+
await act(async () => {
|
|
120
|
+
await testModal.open({ userId: '789' })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(result.current.visible).toBe(true)
|
|
124
|
+
expect(result.current.params.userId).toBe('789')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('request system', () => {
|
|
129
|
+
it('should resolve request', async () => {
|
|
130
|
+
interface ConfirmResult {
|
|
131
|
+
confirmed: boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const confirmModal = modal<{ message: string }, ConfirmResult>()
|
|
135
|
+
.content((props) => {
|
|
136
|
+
const { request, message } = props
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div>
|
|
140
|
+
<p>{message}</p>
|
|
141
|
+
<button onClick={() => request?.resolve({ confirmed: true })}>
|
|
142
|
+
Yes
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const requestPromise = confirmModal.request({ message: 'Continue?' })
|
|
149
|
+
|
|
150
|
+
// Simulate user clicking yes
|
|
151
|
+
await act(async () => {
|
|
152
|
+
confirmModal.resolve({ confirmed: true })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const result = await requestPromise
|
|
156
|
+
|
|
157
|
+
expect(result.confirmed).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('should have pending request', async () => {
|
|
161
|
+
const confirmModal = modal()
|
|
162
|
+
|
|
163
|
+
expect(confirmModal.hasPendingRequest).toBe(false)
|
|
164
|
+
|
|
165
|
+
const promise = confirmModal.request()
|
|
166
|
+
|
|
167
|
+
expect(confirmModal.hasPendingRequest).toBe(true)
|
|
168
|
+
|
|
169
|
+
await act(async () => {
|
|
170
|
+
confirmModal.resolve(null)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
await promise
|
|
174
|
+
|
|
175
|
+
expect(confirmModal.hasPendingRequest).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('lifecycle hooks', () => {
|
|
180
|
+
it('should call onOpen callback', async () => {
|
|
181
|
+
let openCalled = false
|
|
182
|
+
|
|
183
|
+
const hookModal = modal()
|
|
184
|
+
.onOpen(() => {
|
|
185
|
+
openCalled = true
|
|
186
|
+
})
|
|
187
|
+
.content(() => <div>Content</div>)
|
|
188
|
+
|
|
189
|
+
await hookModal.open()
|
|
190
|
+
|
|
191
|
+
expect(openCalled).toBe(true)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should call onClose callback', async () => {
|
|
195
|
+
let closeCalled = false
|
|
196
|
+
|
|
197
|
+
const hookModal = modal()
|
|
198
|
+
.onClose(() => {
|
|
199
|
+
closeCalled = true
|
|
200
|
+
})
|
|
201
|
+
.content(() => <div>Content</div>)
|
|
202
|
+
|
|
203
|
+
await hookModal.open()
|
|
204
|
+
await hookModal.close()
|
|
205
|
+
|
|
206
|
+
expect(closeCalled).toBe(true)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('props configuration', () => {
|
|
211
|
+
it('should set static props', () => {
|
|
212
|
+
const propsModal = modal()
|
|
213
|
+
.props({ title: 'Test Modal' })
|
|
214
|
+
|
|
215
|
+
expect(propsModal._wrapperProps.get().title).toBe('Test Modal')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('should set dynamic props', async () => {
|
|
219
|
+
const dynamicModal = modal<{ itemName: string }>()
|
|
220
|
+
.props((params) => ({
|
|
221
|
+
title: `Editing ${params.itemName}`,
|
|
222
|
+
}))
|
|
223
|
+
|
|
224
|
+
// Dynamic props are evaluated in component render
|
|
225
|
+
// Just verify the function is set
|
|
226
|
+
expect(dynamicModal._lazyWrapperProps).toBeDefined()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './portal'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AnyFunction } from '@codeleap/types'
|
|
2
|
+
|
|
3
|
+
type AllButFirst<T extends any[]> = T extends [infer _, ...infer Rest] ? Rest : []
|
|
4
|
+
|
|
5
|
+
export type ExcludeFromParam<
|
|
6
|
+
T extends AnyFunction,
|
|
7
|
+
E = any
|
|
8
|
+
> = (p?: Exclude<Parameters<T>[0], E>, ...args: AllButFirst<Parameters<T>>) => ReturnType<T>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Ref } from 'react'
|
|
2
|
+
|
|
3
|
+
export type PortalParams<P = {}, Result = {}> = {
|
|
4
|
+
visible: boolean
|
|
5
|
+
toggle: () => void
|
|
6
|
+
close: () => void
|
|
7
|
+
open: () => void
|
|
8
|
+
setParams: (next: Partial<P> | ((prev: P) => Partial<P>)) => void
|
|
9
|
+
request?: {
|
|
10
|
+
resolve: (result: Result) => void
|
|
11
|
+
reject: (reason: unknown) => void
|
|
12
|
+
}
|
|
13
|
+
} & P
|
|
14
|
+
|
|
15
|
+
export type RenderPortalContent<Params = {}, Result = {}, RefType = any> = (
|
|
16
|
+
props: PortalParams<Params, Result>,
|
|
17
|
+
ref?: React.RefObject<RefType>
|
|
18
|
+
) => React.ReactElement
|
|
19
|
+
|
|
20
|
+
export type PortalConfig<P, M = {}> = {
|
|
21
|
+
initialParams?: P | (() => Promise<P>)
|
|
22
|
+
startsOpen?: boolean | (() => Promise<boolean>)
|
|
23
|
+
independent?: boolean
|
|
24
|
+
rendersWhenHidden?: boolean
|
|
25
|
+
metadata?: M
|
|
26
|
+
transitionDuration?: number
|
|
27
|
+
resetParamsOnClose?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PortalConstructorParam<Params = {}, Metadata = {}> = (Partial<PortalConfig<Params, Metadata>> & { id?: string }) | string
|
|
31
|
+
|
|
32
|
+
export type PortalWrapperProps<P, R> = P & {
|
|
33
|
+
close: () => void
|
|
34
|
+
open: () => void
|
|
35
|
+
toggle: () => void
|
|
36
|
+
zIndex: number
|
|
37
|
+
visible: boolean
|
|
38
|
+
ref: Ref<R>
|
|
39
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { waitFor } from '@codeleap/utils'
|
|
2
|
+
|
|
3
|
+
export const randomId = () => {
|
|
4
|
+
return Math.random().toString(36).slice(2, 9)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function awaitTransition(count?: number, duration = 1000) {
|
|
8
|
+
for (let i = 0; i < (count ?? 1); i++) {
|
|
9
|
+
await waitFor(duration)
|
|
10
|
+
}
|
|
11
|
+
}
|