UNPKG

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