dynamic-modal
Version:
The dynamic-modal is a solution of creation different modals into project using a json configuration file
776 lines (646 loc) • 17.8 kB
Markdown
# dynamic-modal
`dynamic-modal` is a React library for building configurable modals from JSON.
Instead of hand-writing a modal UI every time, you describe fields, actions, and
conditional behavior in a config object and open it through a hook.
It is designed for projects that want:
- reusable modal definitions
- dynamic forms inside modals
- conditional rendering with `renderIf`
- conditional enabling with `enableIf`
- dependent remote options with `liveData`
- full UI customization through your own design system components
## Compatibility
According to `package.json`, this library is compatible with:
- `react`: `^18.0.0 || ^19.0.0`
- `react-dom`: `^18.0.0 || ^19.0.0`
- `react-hook-form`: `^7.54.2`
The library itself is currently built with:
- `react`: `^19.0.0`
- `react-dom`: `^19.0.0`
- `react-hook-form`: `^7.54.2`
It works well in React apps and in Next.js projects that support client
components.
## Installation
```bash
npm install dynamic-modal
```
If your project does not already include the required peers, install them too:
```bash
npm install react react-dom react-hook-form
```
## Exports
The package exposes:
- `DynamicModal`
- `useModalHandler`
- `ComponentState`
- `ComponentStateContext`
- `IComponentState`
- `IModalConfigLoader`
- `IModalConfigProps`
- `IModalRenderCondition`
- `IModalField`
- `IModalLiveDataCondition`
- `IOption`
## Mental model
You use the library in 4 steps:
1. Define the UI components the modal should use in your app.
2. Wrap your app with `ComponentState`.
3. Render `DynamicModal` and control it with `useModalHandler`.
4. Build modal configs as plain objects and open them when needed.
## 1. Provide your own components
`dynamic-modal` does not force a UI kit on you.
You provide your own inputs, selects, buttons, toggles, and textarea components
through `ComponentState`, so the modal matches your app visually.
Example:
```tsx
'use client';
import { ReactNode } from 'react';
import {
Autocomplete,
AutocompleteItem,
Button,
Input,
Select,
SelectItem,
Switch,
Textarea,
} from '@heroui/react';
import type { IComponentState } from 'dynamic-modal';
export const modalComponents: IComponentState = {
ModalButtonCancel: ({ text, color, ...props }) => (
<Button {...props} color={color as any} variant="bordered">
{text}
</Button>
),
ModalButtonAction: ({ text, color, ...props }) => (
<Button {...props} color={color as any} variant="solid">
{text}
</Button>
),
Button: ({ text, color, variant, ...props }) => (
<Button {...props} color={color as any} variant={variant as any}>
{text}
</Button>
),
Input: ({ invalid, error, disabled, onChange, value, ...props }) => (
<Input
{...props}
value={value ?? ''}
onValueChange={onChange}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
/>
),
Select: ({
options,
invalid,
error,
isMulti,
isSearch,
disabled,
onChange,
value,
...props
}) =>
isSearch ? (
<Autocomplete
{...props}
selectedKey={value}
onSelectionChange={onChange as any}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
>
{options.map((item) => (
<AutocompleteItem key={item.id}>{item.name}</AutocompleteItem>
))}
</Autocomplete>
) : (
<Select
{...props}
selectedKeys={isMulti ? (value ?? []) : value ? [value] : []}
onSelectionChange={onChange as any}
selectionMode={isMulti ? 'multiple' : 'single'}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
>
{options.map((option) => (
<SelectItem key={option.id}>{option.name}</SelectItem>
))}
</Select>
),
Textarea: ({ invalid, error, disabled, value, onChange, ...props }) => (
<Textarea
{...props}
value={value ?? ''}
onValueChange={onChange}
errorMessage={error?.message}
isInvalid={invalid}
isDisabled={disabled}
/>
),
Toggle: ({ value, onChange, label, ...props }) => (
<Switch {...props} isSelected={!!value} onValueChange={onChange}>
{label}
</Switch>
),
};
export function ModalProvider({ children }: { children: ReactNode }) {
return <ComponentState components={modalComponents}>{children}</ComponentState>;
}
```
## 2. Add the provider and portal
Wrap your app with `ComponentState` and add a portal target with the id
`modal-portal`.
### Next.js App Router
```tsx
import type { ReactNode } from 'react';
export default function RootLayout({
children,
}: Readonly<{ children: ReactNode }>) {
return (
<html lang="en">
<body>
<ModalProvider>{children}</ModalProvider>
<div id="modal-portal" />
</body>
</html>
);
}
```
### Next.js Pages Router
```tsx
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<div id="modal-portal" />
<NextScript />
</body>
</Html>
);
}
```
## 3. Render and control the modal
Use `useModalHandler` to open the modal and render `DynamicModal` once in your
page or component tree.
```tsx
'use client';
import { DynamicModal, useModalHandler } from 'dynamic-modal';
import { Button } from '@heroui/react';
import simpleModal from './modal-config/simple-modal';
export default function ExamplePage() {
const { openModal, modalProps } = useModalHandler();
return (
<>
<Button
onClick={() => {
openModal(
simpleModal.default(
{ reserved: 'abc', input1: 'Initial value', store: false },
(data) => {
console.log('modal result', data);
},
),
);
}}
>
Open modal
</Button>
<DynamicModal {...modalProps} />
</>
);
}
```
## 4. Create modal configs
The recommended pattern is to define modal configs with `IModalConfigLoader`.
This lets you:
- receive input props
- return a typed modal config
- receive typed modal output in the `action` callback
Basic example:
```ts
import type { IModalConfigLoader } from 'dynamic-modal';
type IncomingProps = {
reserved: string;
input1: string;
store?: boolean;
clear?: boolean;
};
type ResultProps = IncomingProps;
const simpleModal: {
default: IModalConfigLoader<IncomingProps, ResultProps>;
} = {
default: (props, action) => ({
reservedData: {
reserved: props.reserved,
},
title: 'Basic modal',
style: {
width: '500px',
},
fields: [
{
elementType: 'input',
label: 'Input 1',
name: 'input1',
defaultValue: props.input1,
validation: {
required: true,
message: 'This field is required',
},
},
{
elementType: 'group',
groups: [
{
elementType: 'toggle',
label: 'Store',
name: 'store',
defaultValue: `${props.store ?? false}`,
style: { width: '50%' },
validation: {
required: false,
},
},
{
elementType: 'toggle',
label: 'Clear',
name: 'clear',
defaultValue: `${props.clear ?? false}`,
style: { width: '50%' },
validation: {
required: false,
},
},
],
},
],
out: action,
actions: {
action: { text: 'Save', color: 'primary' },
cancel: { text: 'Cancel', color: 'danger' },
},
}),
};
export default simpleModal;
```
## Supported field types
You can build modal UIs with these field types:
- `input`
- `select`
- `textarea`
- `toggle`
- `text`
- `upload`
- `custom-upload`
- `watcher`
- `button`
- `table`
- `group`
`group` lets you place multiple fields in the same row.
## Conditional behavior
One of the main strengths of the library is dynamic behavior based on form
state.
### `renderIf`
Use `renderIf` when a field should appear only if another field matches one or
more values.
```ts
{
elementType: 'input',
label: 'Company name',
name: 'companyName',
validation: {
required: true,
message: 'Write a company name',
},
renderIf: {
personType: ['company'],
},
}
```
You can also use `'*'` as a wildcard:
```ts
renderIf: {
personType: ['*'],
}
```
### `enableIf`
Use `enableIf` when a field should stay visible but only become editable if a
condition is met.
```ts
{
elementType: 'input',
label: 'Discount code',
name: 'discountCode',
validation: {
required: false,
},
enableIf: {
hasDiscount: ['true'],
},
}
```
### `liveData`
Use `liveData` when one field depends on another and must fetch options
dynamically.
```ts
{
elementType: 'select',
label: 'City',
name: 'cityId',
options: [],
validation: {
required: true,
message: 'Select a city',
},
liveData: {
condition: ['countryId'],
action: async (countryId, formData) => {
const response = await fetch(`/api/cities?countryId=${countryId}`);
const data = await response.json();
return data.map((city: { id: string; name: string }) => ({
id: city.id,
name: city.name,
}));
},
},
}
```
### `watcher`
Use `watcher` when you want to display a derived read-only value built from
other fields in the same modal.
`watcher` listens to the fields listed in `watchList`, joins their current
values, and renders the result using your custom `Input` component in disabled
mode.
Example:
```ts
{
elementType: 'watcher',
label: 'Full name preview',
watchList: ['firstName', 'middleName', 'lastName'],
style: {
width: '100%',
},
}
```
Typical use cases:
- preview a full name from multiple inputs
- build a quick summary field for the user
- show a composed display value without storing it as a real form field
## Advanced conditions with async actions
`renderIf` and `enableIf` can also use async logic instead of static value maps.
This is useful if the decision depends on the backend or on custom business
rules.
Example:
```ts
renderIf: {
condition: ['customerId'],
action: async (customerId, formData) => {
const response = await fetch(`/api/customers/${customerId}/can-edit`);
const data = await response.json();
return data.allowed;
},
}
```
The same shape works for `enableIf`.
## Variants and combinations (`renderIf`, `enableIf`, `liveData`)
The library supports these variants:
| Feature | Variant | Shape |
| --- | --- | --- |
| `renderIf` | static criteria | `renderIf: { fieldName: ['value1', 'value2'] }` |
| `renderIf` | wildcard | `renderIf: { fieldName: ['*'] }` |
| `renderIf` | async action | `renderIf: { condition: ['fieldName'], action: async (...) => boolean }` |
| `enableIf` | static criteria | `enableIf: { fieldName: ['value1', 'value2'] }` |
| `enableIf` | wildcard | `enableIf: { fieldName: ['*'] }` |
| `enableIf` | async action | `enableIf: { condition: ['fieldName'], action: async (...) => boolean }` |
| `liveData` | single trigger field | `liveData: { condition: ['fieldName'], action: async (...) => IOption[] }` |
| `liveData` | multiple trigger fields | `liveData: { condition: ['fieldA', 'fieldB'], action: async (...) => IOption[] }` |
Supported combinations by field type:
- `input`, `textarea`, `toggle`, `upload`, `custom-upload`: `renderIf` + `enableIf`
- `select`: `renderIf` + `enableIf` + `liveData`
- `table`: `renderIf` + `liveData`
- `watcher`: no `renderIf`/`enableIf`/`liveData` contract in its interface
Behavior note about multiple observed fields:
- In static mode (`Record<field, values>`), conditions are evaluated per field-change event.
- In async mode (`condition: [...]`), `action` receives the changed field value as first argument and the whole form as second argument.
- For `liveData`, when options refresh, the target field value is reset to its default (`defaultValue`) or `[]` in multi-select mode.
Minimal combination example (`select` with all three):
```ts
{
elementType: 'select',
label: 'Options',
name: 'optionId',
options: [],
validation: { required: true, message: 'Required' },
renderIf: { typeId: ['*'] },
enableIf: { statusId: ['approved'] },
liveData: {
condition: ['typeId', 'statusId'],
action: async (changedValue, formData) => readOptions(changedValue, formData),
},
}
```
## Examples by use case
### 1. Basic modal
Use this when you just need a standard modal with fixed fields.
```ts
fields: [
{
elementType: 'input',
label: 'Name',
name: 'name',
validation: { required: true, message: 'Required' },
},
{
elementType: 'textarea',
label: 'Description',
name: 'description',
validation: { required: false },
},
];
```
### 2. Render fields depending on a select
Use `renderIf` for mutually exclusive sections.
```ts
fields: [
{
elementType: 'select',
label: 'Mode',
name: 'mode',
defaultValue: 'email',
options: [
{ id: 'email', name: 'Email' },
{ id: 'sms', name: 'SMS' },
],
validation: { required: true, message: 'Select a mode' },
},
{
elementType: 'input',
label: 'Email',
name: 'email',
validation: { required: true, message: 'Write an email' },
renderIf: { mode: ['email'] },
},
{
elementType: 'input',
label: 'Phone',
name: 'phone',
validation: { required: true, message: 'Write a phone' },
renderIf: { mode: ['sms'] },
},
];
```
### 3. Keep the field visible but disabled
Use `enableIf` if the user should see the field before it becomes available.
```ts
{
elementType: 'input',
label: 'Approval note',
name: 'approvalNote',
validation: { required: false },
enableIf: {
status: ['approved'],
},
}
```
### 4. Load options from another field
Use `liveData` for dependent selects.
```ts
fields: [
{
elementType: 'select',
label: 'Type',
name: 'typeId',
options: props.typeList,
validation: {
required: true,
message: 'Please select a valid type',
},
},
{
elementType: 'select',
label: 'Options',
name: 'optionId',
options: [],
validation: {
required: true,
message: 'Please select a valid option',
},
liveData: {
condition: ['typeId'],
action: props.optionReadAction,
},
},
];
```
### 5. Reserve data that should travel with the result
Use `reservedData` when you want to preserve contextual information without
showing it in the modal.
```ts
reservedData: {
customerId: props.customerId,
source: 'customer-profile',
}
```
That data will be merged into the object returned by `out`.
### 6. Compose a read-only value with `watcher`
Use `watcher` when you want the modal to display a value derived from multiple
fields while the user types.
```ts
fields: [
{
elementType: 'input',
label: 'First name',
name: 'firstName',
validation: { required: true, message: 'Required' },
},
{
elementType: 'input',
label: 'Last name',
name: 'lastName',
validation: { required: true, message: 'Required' },
},
{
elementType: 'watcher',
label: 'Preview',
watchList: ['firstName', 'lastName'],
style: { width: '100%' },
},
];
```
Important notes:
- `watcher` is display-only
- it does not submit its own value in the modal result
- it is useful for previews, concatenations, and human-readable summaries
## Configuration reference
### Modal-level config
Common properties of `IModalConfigProps`:
- `title`: modal title
- `fields`: list of modal elements
- `out`: callback invoked on submit
- `reservedData`: extra data merged into the result
- `onClose`: callback when the modal closes
- `style`: styles for the modal container
- `overFlowBody`: body height/overflow control
- `minHeightBody`: minimum body height
- `useSubmit`: if `false`, action button uses manual validation mode
- `useBlur`: enables backdrop blur style
- `layout`: section-level customization for:
- `container`
- `header` (`showDivider?: boolean`)
- `title`
- `body`
- `footer` (`showDivider?: boolean`)
Each section supports `className` and `style`.
- `actions.action`: main action button props
- `actions.cancel`: optional cancel button props
- `actions.containerStyle`: style for the action buttons container
### Common field properties
Most form fields share:
- `name`
- `label`
- `placeholder`
- `defaultValue`
- `style`
- `customProperties`
- `disabled`
- `validation`
- `renderIf`
- `enableIf`
Most field interfaces now also accept native HTML attributes according to the
element type (`input`, `textarea`, `button`, `select`, etc.). These extra props
are forwarded with the rest of the field config.
`watcher` uses:
- `label`
- `style`
- `customProperties`
- `watchList`
Validation supports:
- `required`
- `message`
- `regex`
- `maxLength`
- `minLength`
- `min`
- `max`
## Notes and recommendations
- Render `DynamicModal` only once per screen or page branch when possible.
- Prefer stable `name` values because they are used to manage form state.
- Use `renderIf` for hidden sections and `enableIf` for visible-but-locked
sections.
- Keep `liveData` actions fast and deterministic when possible.
- If your custom UI components use different event contracts, adapt them inside
`ComponentState` rather than changing modal configs.
## Repository examples
This repository includes working examples in:
- `examples/simple.ts`
- `examples/render-if.ts`
- `examples/enable-if.ts`
- `examples/live-data.ts`
These are useful starting points for building your own modal catalog.