@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 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
+ }
@@ -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
+ }