react-native-modalfy
Version:
Modal citizen of React Native
424 lines (370 loc) • 13 kB
text/typescript
import { BackHandler } from 'react-native'
import type {
ModalfyParams,
ModalStackItem,
ModalStateListener,
ModalInternalState,
ModalStateSubscriber,
ModalStateSubscription,
ModalStateEqualityChecker,
ModalPendingClosingAction,
ModalState as ModalStateType,
ModalStatePendingClosingAction,
} from '../types'
import { invariant, getStackItemOptions, defaultOptions } from '../utils'
const createModalState = (): ModalStateType<any> => {
let state: ModalInternalState<any> = {
currentModal: null,
stack: {
names: [],
content: [],
defaultOptions,
openedItems: new Set(),
pendingClosingActions: new Set(),
},
}
const stateListeners: Set<() => void> = new Set()
const setState = <P extends ModalfyParams>(
updater: (currentState: ModalInternalState<P>) => ModalInternalState<P>,
) => {
state = updater(getState())
stateListeners.forEach(stateListener => stateListener())
return state
}
const init = setState
const getState = <P extends ModalfyParams>(): ModalInternalState<P> => state
const addStateSubscriber = <P extends ModalfyParams>(
stateSubscriber: ModalStateSubscriber<P>,
): ModalStateSubscription<P> => {
function stateListener() {
try {
const currentState = getState<P>()
if (!stateSubscriber.equalityFn(stateSubscriber.state, currentState)) {
stateSubscriber.stateListener((stateSubscriber.state = currentState))
}
} catch (error) {
stateSubscriber.error = true
stateSubscriber.stateListener(null, error as Error)
}
}
stateListeners.add(stateListener)
return { unsubscribe: () => stateListeners.delete(stateListener) }
}
const createStateSubscriber = <P extends ModalfyParams>(
stateListener: ModalStateListener<P>,
equalityFn: ModalStateEqualityChecker<P>,
): ModalStateSubscriber<P> => ({
equalityFn,
error: false,
stateListener,
state: getState(),
unsubscribe: () => true,
})
const subscribe = <P extends ModalfyParams>(
stateListener: ModalStateListener<P>,
equalityFn: ModalStateEqualityChecker<P> = Object.is,
): ModalStateSubscription<P> => addStateSubscriber(createStateSubscriber(stateListener, equalityFn))
const openModal = <P extends ModalfyParams>({
modalName,
params,
isCalledOutsideOfContext,
callback,
}: {
modalName: Exclude<keyof P, symbol | number>
params?: P
isCalledOutsideOfContext?: boolean
callback?: () => void
}) => {
const {
currentModal,
stack: { content, names },
} = getState()
invariant(modalName, "You didn't pass any modal name")
invariant(
names.some(name => name === modalName),
`'${modalName}' is not a valid modal name. Did you mean any of these: ${names.map(
validName => `\n• ${validName}`,
)}`,
)
const stackItem = content.find(item => item.name === modalName)
const hash = `${modalName}_${Math.random().toString(36).substring(2, 11)}`
if (!currentModal && isCalledOutsideOfContext) {
BackHandler.addEventListener('hardwareBackPress', handleBackPress)
}
if (stackItem) {
setState<P>(currentState => ({
currentModal: modalName,
stack: {
...currentState.stack,
openedItems: currentState.stack.openedItems.add(
Object.assign({}, stackItem, {
...stackItem,
hash,
callback,
...(params && { params }),
}) as ModalStackItem<ModalfyParams>,
),
},
}))
}
}
const getParam = <P extends ModalfyParams, N extends keyof P[keyof P], D extends P[keyof P][N]>(
hash: ModalStackItem<P>['hash'],
paramName: N,
defaultValue?: D,
): D extends P[keyof P][N] ? P[keyof P][N] : undefined => {
const {
stack: { openedItems },
} = getState()
let stackItem: ModalStackItem<P> | undefined
openedItems.forEach(item => {
if (item.hash === hash) stackItem = item
})
return stackItem?.params?.[paramName] ?? defaultValue
}
const closeModal = <P extends ModalfyParams>(
closingElement?: Exclude<keyof P, number | symbol> | ModalStackItem<P>,
) => {
const {
stack: { openedItems: oldOpenedItems, names },
} = getState()
const newOpenedItems = new Set(oldOpenedItems)
if (typeof closingElement === 'string') {
invariant(
names.some(name => name === closingElement),
`'${closingElement}' is not a valid modal name. Did you mean any of these: ${names.map(
validName => `\n• ${String(validName)}`,
)}`,
)
let wasItemRemoved = false
const reversedOpenedItemsArray = Array.from(newOpenedItems).reverse()
reversedOpenedItemsArray.forEach(openedItem => {
if (openedItem.name === closingElement && !wasItemRemoved) {
newOpenedItems.delete(openedItem)
wasItemRemoved = true
}
})
if (!wasItemRemoved) {
console.warn(`There was no opened ${closingElement} modal.`)
}
// @ts-ignore
} else if (closingElement && newOpenedItems.has(closingElement)) {
// @ts-ignore
newOpenedItems.delete(closingElement)
} else {
const staleStackItem = Array.from(newOpenedItems).pop()
if (staleStackItem) newOpenedItems.delete(staleStackItem)
}
const newOpenedItemsArray = Array.from(newOpenedItems)
setState(currentState => ({
stack: { ...currentState.stack, openedItems: newOpenedItems },
currentModal: newOpenedItemsArray?.[newOpenedItemsArray?.length - 1]?.name,
}))
}
const closeModals = <P extends ModalfyParams>(modalName: Exclude<keyof P, symbol>): boolean => {
const {
stack: { openedItems: oldOpenedItems, names },
} = getState()
invariant(modalName, "You didn't pass any modal name to closeModals()")
invariant(
names.some(name => name === modalName),
`'${modalName}' is not a valid modal name. Did you mean any of these: ${names.map(
validName => `\n• ${String(validName)}`,
)}`,
)
const newOpenedItems = new Set(oldOpenedItems)
newOpenedItems.forEach(item => {
if (item.name === modalName) newOpenedItems.delete(item)
})
if (newOpenedItems.size !== oldOpenedItems.size) {
const openedItemsArray = Array.from(newOpenedItems)
setState(currentState => ({
stack: { ...currentState.stack, openedItems: newOpenedItems },
currentModal: openedItemsArray?.[openedItemsArray?.length - 1]?.name,
}))
return true
}
return false
}
const closeAllModals = () => {
setState(currentState => ({
currentModal: null,
stack: { ...currentState.stack, openedItems: new Set() },
}))
}
const handleBackPress = (): boolean => {
const { currentModal, stack } = getState()
const currentModalStackItem = Array.from(stack.openedItems).pop()
const { backBehavior } = getStackItemOptions(currentModalStackItem, stack)
if (currentModal) {
if (backBehavior === 'none') return true
else if (backBehavior === 'clear') {
queueClosingAction({ action: 'closeAllModals' })
return true
} else if (backBehavior === 'pop') {
queueClosingAction({ action: 'closeModal', modalName: currentModal })
return true
}
}
return false
}
const queueClosingAction = ({
action,
callback,
modalName,
}: ModalStatePendingClosingAction): ModalPendingClosingAction | null => {
const {
stack: { names, openedItems },
} = getState()
if (action !== 'closeAllModals' && modalName) {
invariant(
names.some(name => name === modalName),
`'${modalName}' is not a valid modal name. Did you mean any of these: ${names.map(
validName => `\n• ${validName}`,
)}`,
)
}
const noOpenedItems = !openedItems?.size
if (noOpenedItems) {
if (typeof callback === 'function') callback?.()
return null
}
const hash = `${modalName ? `${modalName}_${action}` : action}_${Math.random().toString(36).substring(2, 11)}`
const { pendingClosingActions } = setState(currentState => {
const newPendingClosingActions = new Set(currentState.stack.pendingClosingActions)
newPendingClosingActions.add({
hash,
action,
callback,
modalName,
currentModalHash: [...currentState.stack.openedItems].slice(-1)[0]?.hash,
} as ModalPendingClosingAction)
return {
...currentState,
stack: {
...currentState.stack,
pendingClosingActions: newPendingClosingActions,
},
}
}).stack
return [...pendingClosingActions].slice(-1)[0]
}
const removeClosingAction = (action: ModalPendingClosingAction): boolean => {
const {
stack: { pendingClosingActions: oldPendingClosingActions },
} = getState()
const newPendingClosingActions = new Set(oldPendingClosingActions)
if (newPendingClosingActions.has(action)) {
newPendingClosingActions.delete(action)
} else return false
setState(currentState => ({
...currentState,
stack: {
...currentState.stack,
pendingClosingActions: newPendingClosingActions,
},
}))
return true
}
return {
init,
setState,
getState,
getParam,
openModal,
subscribe,
closeModal,
closeModals,
closeAllModals,
handleBackPress,
queueClosingAction,
removeClosingAction,
}
}
const ModalState = createModalState()
/**
* Function that exposes Modalfy's API outside of React's context.
*
* Note: Do not use if you're inside a React component.
* Please consider `useModal()` or `withModal()` instead.
*
* @returns Object containing all the functions and variables of the usual `modal` prop.
*
* @see https://colorfy-software.gitbook.io/react-native-modalfy/api/modalfy
*/
export const modalfy = <
P extends ModalfyParams,
M extends Exclude<keyof P, symbol | number> = Exclude<keyof P, symbol | number>,
>() => ({
/**
* This function closes every open modal.
*
* @example modalfy().closeAllModals(() => console.log('All modals closed'))
*
* @see https://colorfy-software.gitbook.io/react-native-modalfy/api/types/modalprop#closeallmodals
*/
closeAllModals: (callback?: () => void) => {
ModalState.queueClosingAction({ action: 'closeAllModals', callback })
},
/**
* This function closes the currently displayed modal by default.
*
* You can also provide a `modalName` if you want to close a different modal
* than the latest opened. This will only close the latest instance of that modal,
* see `closeModals()` if you want to close all instances.
*
* @example modalfy().closeModal('ExampleModal', () => console.log('Current modal closed'))
*
* @see https://colorfy-software.gitbook.io/react-native-modalfy/api/types/modalprop#closemodal
*/
closeModal: (modalName?: M, callback?: () => void) => {
ModalState.queueClosingAction({
action: 'closeModal',
modalName,
callback,
})
},
/**
* This function closes all the instances of a given modal.
*
* You can use it whenever you have the same modal opened
* several times, to close all of them at once.
*
* @example modalfy().closeModals('ExampleModal', () => console.log('All ExampleModal modals closed'))
*
* @returns { boolean } Whether or not Modalfy found any open modal
* corresponding to `modalName` (and then closed them).
*
* @see https://colorfy-software.gitbook.io/react-native-modalfy/api/types/modalprop#closemodals
*/
closeModals: (modalName: M, callback?: () => void) => {
ModalState.queueClosingAction({
action: 'closeModals',
modalName,
callback,
})
},
/**
* This value returns the current open modal (`null` if none).
*
* @example modalfy().currentModal
*
* @see https://colorfy-software.gitbook.io/react-native-modalfy/api/types/modalprop#currentmodal
*/
currentModal: ModalState.getState<P>()?.currentModal ?? null,
/**
* This function opens a modal based on the provided `modalName`.
*
* It will look at the stack passed to `<ModalProvider>` and add
* the corresponding component to the current stack of open modals.
* Alternatively, you can also provide some `params` that will be
* accessible to that component.
*
* @example modalfy().openModal('PokédexEntryModal', { id: 619, name: 'Lin-Fu' }, () => console.log('PokédexEntryModal modal opened'))
*
* @see https://colorfy-software.gitbook.io/react-native-modalfy/api/types/modalprop#openmodal
*/
openModal: (modalName: M, params?: P[M], callback?: () => void) =>
ModalState.openModal({ modalName, params, callback, isCalledOutsideOfContext: true }),
})
export default ModalState