Programmatic control of modals

Introduction

The PlumaModal component is used to display modal dialogs. Using the PlumaModal component directly, however, can be difficult to manage, as it requires manually keeping track of the modal's open state. This also makes it difficult to reuse the same modal in multiple places, as it involves extra boilerplate code.

Pluma's Modal Manager is a utility that simplifies the creation and management of modals.

Defining a managed modal

To use a modal via the manager, we first need to define a modal component, which will later be referenced in the manager.

Pluma exports utility types that help define the component's props - it's highly recommended to use these types to ensure type safety and type hints.

import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

PlumaManagedModalProps<Props, ConfirmArg, CloseArg> is a generic type that wraps up the props that are passed into a modal component invoked by the modal manager. It takes three generic parameters:

  1. Props: The custom props you plan to pass into the modal component
  2. ConfirmArg: The argument you will pass into the onConfirm callback
  3. CloseArg: The argument you will pass into the onClose callback

To define a modal component, you need to use this type as your components' props type.

import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

import {
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';

export function MyModal(props: PlumaManagedModalProps<{ description: string }, string, string>) {
	const { modalState, data, onClose, onConfirm } = props;

	return (
		<Modal state={modalState} title="My modal title">
			<ModalBody>
				<Paragraph>The description is: {data.description}</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose('Add a close reason here')}>Close</Button>
				<Button onClick={() => onConfirm('Add a confirm reason here')}>Submit</Button>
			</ModalFooter>
		</Modal>
	);
}

When the modal is opened, the manager will pass any of your custom Props into the modal component under the data key. Additionally, two callbacks are available:

  • onClose: A callback to call when you want to close the modal when the user cancels an action
  • onConfirm: A callback for when the user confirms an action, or otherwise performs a CTA-like action
    • this will also close the modal - but it allows you to differentiate between different actions performed by the user

Finally, a modalState object is also present. It is required to forward this prop into the PlumaModal's state prop - it holds the modal's open state and other modal-related props.

Using the managed modal

Now that we have defined a managed modal component, we can use it with the manager.

The useModals hook returns the modal manager, which allows you to open and interact with modals.

To open a modal, you can use the open method. As the first argument it accepts the modal component you previously defined. As the second argument, it expects the additional props that you expect to see under data.

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';
import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

function MyModal(props: PlumaManagedModalProps<{ description: string }, string, string>) {
	const { modalState, data, onClose, onConfirm } = props;

	return (
		<Modal state={modalState} title="My modal title">
			<ModalBody>
				<Paragraph>The description is: {data.description}</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose('Add a close reason here')}>Close</Button>
				<Button onClick={() => onConfirm('Add a confirm reason here')}>Submit</Button>
			</ModalFooter>
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyModal, { description: 'This is a test modal.' });
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

There is also a useModal hook, which accepts a modal component as its argument and returns a modal manager API bound to that specific component. It's a shortcut to make it a little easier to reuse the same modal in different places.

import {
	useModal,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';
import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

function MyModal(props: PlumaManagedModalProps<{ description: string }, string, string>) {
	const { modalState, data, onClose, onConfirm } = props;

	return (
		<Modal state={modalState} title="My modal title">
			<ModalBody>
				<Paragraph>The description is: {data.description}</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose('Add a close reason here')}>Close</Button>
				<Button onClick={() => onConfirm('Add a confirm reason here')}>Submit</Button>
			</ModalFooter>
		</Modal>
	);
}

function useMyModal() {
	return useModal(MyModal);
}

export default function Example() {
	const myModal = useMyModal();

	const handleClick = () => {
		myModal.open({ description: 'This is a test modal.' });
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Handling modal closure

The manager's open method returns a unique identifier for the opened modal. This identifier can be used in the manager's waitForClose method, which returns a promise that resolves when the modal is closed.

The value returned from this resolved promise is an object with the following properties:

  • reason: The reason the modal was closed, a string with one of the following values:
    • close - called when the modal is closed with the onClose callback, or when the modal is closed by clicking the backdrop, the X close button, or by pressing the escape key (if those are enabled)
    • confirm - called when the modal is closed with the onConfirm callback
  • data: The data that was passed to the modal's onClose or onConfirm callback
  • explanation: The explanation for the modal closure. Only present when the modal is closed through the modal's "native" close mechanisms, such as clicking the backdrop. The explanation is a string with one of Floating UI's onOpenChange reasons, close-button-press if closed by clicking the modal's X close button, or modal-manager:close-all if closed by calling the manager's closeAll method

This value can be used to handle the modal closure in the parent component.

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';
import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

function MyModal(props: PlumaManagedModalProps<{ description: string }, string, string>) {
	const { modalState, data, onClose, onConfirm } = props;

	return (
		<Modal state={modalState} title="My modal title">
			<ModalBody>
				<Paragraph>The description is: {data.description}</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose('Add a close reason here')}>Close</Button>
				<Button onClick={() => onConfirm('Add a confirm reason here')}>Submit</Button>
			</ModalFooter>
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const asyncOpen = async () => {
		const id = modals.open(MyModal, { description: 'This is a test modal.' });
		const result = await modals.waitForClose(id);

		if (result.reason === 'confirm') {
			console.log('Modal confirmed with', result);
		} else if (result.reason === 'close') {
			console.log('Modal closed with', result);
		}
	};

	const handleClick = () => {
		asyncOpen();
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Updating a modal's data props

Sometimes it might be necessary to update a modal's data props after it has been opened. This can be done with the manager's update method. It takes the modal's identifier as the first argument, and the new props as the second argument. The new props will be merged with the existing props, and the modal will be updated accordingly.

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';
import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

function MyModal(props: PlumaManagedModalProps<{ description: string }, string, string>) {
	const { modalState, data, onClose, onConfirm } = props;

	return (
		<Modal state={modalState} title="My modal title">
			<ModalBody>
				<Paragraph>The description is: {data.description}</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose('Add a close reason here')}>Close</Button>
				<Button onClick={() => onConfirm('Add a confirm reason here')}>Submit</Button>
			</ModalFooter>
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const asyncOpen = async () => {
		const id = modals.open(MyModal, { description: 'This is a test modal. This description will update in 2 seconds.' });
		
		setTimeout(() => {
			modals.update(id, { description: 'This is an updated description.' });
		}, 2000);
	};

	const handleClick = () => {
		asyncOpen();
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Customizing modal behavior

If you need to customize any of the Modal component's behavior, you can still use any of it's arguments as usual. For example, if you'd like make the modal non-dismissable, you can still use the isDismissable prop to achieve this.

If the configuration needs to depend on arguments you pass into the modal, you may use your data props to pass that configuration further into the modal.

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';
import type { PlumaManagedModalProps } from '@customerio/pluma-components/react';

function MyModal(props: PlumaManagedModalProps<
	{
		title: string;
		description: string;
		isDismissable: boolean;
	},
	string,
	string,
>) {
	const { modalState, data, onClose, onConfirm } = props;

	return (
		<Modal state={modalState} title={data.title} isDismissable={data.isDismissable}>
			<ModalBody>
				<Paragraph>The description is: {data.description}</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose('Add a close reason here')}>Close</Button>
				<Button onClick={() => onConfirm('Add a confirm reason here')}>Submit</Button>
			</ModalFooter>
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const asyncOpen = async () => {
		const id = modals.open(MyModal, {
			title: 'My modal title',
			description: 'This is a test modal.',
			isDismissable: false,
		});
		
		setTimeout(() => {
			modals.update(id, { isDismissable: true });
		}, 2000);
	};

	const handleClick = () => {
		asyncOpen();
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Closing modals on unmount

Modals opened via the modal manager will stay open until they are explicitly closed, either by user action or programmatically.

However, in some cases, you may want to automatically close a modal when the component that opened it is unmounted. For example, when a route transition happens.

Managed modals support this functionality, which can be enabled by passing an additional configuration argument into the open method: { shouldCloseOnUnmount: true }. For useModals, this will be the third argument, while for useModal this will be the second argument.

import { useModals, Button } from '@customerio/pluma-components/react';
import MyModal from './my-modal';

export default function Example() {
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyModal, undefined, { shouldCloseOnUnmount: true });
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Nested modals

It may sometimes be necessary to render "nested" modals: for example, opening a confirmation modal from an edit/form modal. In those cases, we always want the top-most (the most recently opened) modal to be the only one that reacts to global dismissals like clicking outside or pressing the ESC key.

Linking modals to prevent them from closing when another one is open on top is built into the modal components, and works automatically in many cases. In some cases, however - depending on your component structure - it may be necessary to link the modals explicitly.

The plain Modal component (not the useModals hook) includes a modal context provider. When modals are nested in one another, the nested one can read the parent context, and the linking happens automatically.

However, when a useModals hook is called just outside of a Modal, it won't be able to read that modal's context. useModals needs to be called inside a component that's nested within Modal for this to work.

Let's look at some examples.

Automatic context read

Here, we move the modal's contents into a separate component, so that its useModals hook can automatically read the parent modal's context.

When the nested modal is opened, clicking inside of it won't close its parent modal (as it otherwise might because of it being an "outside click" in regards to the parent), and clicking outside or pressing ESC will only close the nested modal.

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';

function MyNestedModal(props) {
	const { modalState, onClose } = props;

	return (
		<Modal state={modalState} title="Nested modal">
			<ModalBody>
				<Paragraph>This is the nested modal.</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose()}>Close</Button>
			</ModalFooter>
		</Modal>
	);
}

function MyModalBody() {
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyNestedModal);
	};

	return (
		<ModalBody>
			<Button onClick={handleClick}>Open nested modal</Button>
		</ModalBody>
	);
}

function MyModal(props) {
	const { modalState, onClose } = props;

	return (
		<Modal state={modalState} title="Parent modal">
			<MyModalBody />
			<ModalFooter>
				<Button onClick={() => onClose()}>Close</Button>
			</ModalFooter>
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyModal);
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Compare the above to a nested modal that cannot read the parent modal's context:

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';

function MyNestedModal(props) {
	const { modalState, onClose } = props;

	return (
		<Modal state={modalState} title="Nested modal">
			<ModalBody>
				<Paragraph>This is the nested modal.</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose()}>Close</Button>
			</ModalFooter>
		</Modal>
	);
}

function MyModal(props) {
	const { modalState, onClose } = props;
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyNestedModal);
	};

	return (
		<Modal state={modalState} title="Parent modal">
			<ModalBody>
				<Button onClick={handleClick}>Open nested modal</Button>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose()}>Close</Button>
			</ModalFooter>
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyModal);
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}

Manually passing modal context

The Modal component yields its context into its context, making it possible to manually pass it into useModals. The open method accepts an optional third parameter, which is an object (the second parameter is your custom props that are passed into the modal component). One of the keys it accepts is modalContext, which should be the parent context we want the managed modal to recognize.

import {
	useModals,
	Modal,
	ModalBody,
	ModalFooter,
	Paragraph,
	Button,
} from '@customerio/pluma-components/react';

function MyNestedModal(props) {
	const { modalState, onClose } = props;

	return (
		<Modal state={modalState} title="Nested modal">
			<ModalBody>
				<Paragraph>This is the nested modal.</Paragraph>
			</ModalBody>
			<ModalFooter>
				<Button onClick={() => onClose()}>Close</Button>
			</ModalFooter>
		</Modal>
	);
}

function MyModal(props) {
	const { modalState, onClose } = props;

	const modals = useModals();

	const handleClick = (modalContext) => {
		modals.open(MyNestedModal, undefined, { modalContext });
	};

	return (
		<Modal state={modalState} title="Parent modal">
			{(context) => (
				<>
					<ModalBody>
						<Button onClick={() => handleClick(context)}>Open nested modal</Button>
					</ModalBody>
					<ModalFooter>
						<Button onClick={() => onClose()}>Close</Button>
					</ModalFooter>
				</>
			)}
		</Modal>
	);
}

export default function Example() {
	const modals = useModals();

	const handleClick = () => {
		modals.open(MyModal);
	};

	return <Button onClick={handleClick}>Open Modal</Button>;
}