# 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.
