dynamic-modal 1.1.22 → 1.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,203 +1,253 @@
1
1
  # dynamic-modal
2
2
 
3
- `dynamic-modal` is a TypeScript library for creating reusable modals in React and Next.js applications. It uses JSON objects to configure the modal structure, eliminating the need to write HTML. This approach simplifies modal creation and customization, allowing you to open and close modals using a custom hook.
3
+ `dynamic-modal` is a React library for building configurable modals from JSON.
4
+ Instead of hand-writing a modal UI every time, you describe fields, actions, and
5
+ conditional behavior in a config object and open it through a hook.
4
6
 
5
- ## Requirements
7
+ It is designed for projects that want:
6
8
 
7
- To use `dynamic-modal` properly, ensure you have the following dependencies installed:
9
+ - reusable modal definitions
10
+ - dynamic forms inside modals
11
+ - conditional rendering with `renderIf`
12
+ - conditional enabling with `enableIf`
13
+ - dependent remote options with `liveData`
14
+ - full UI customization through your own design system components
8
15
 
9
- - React
16
+ ## Compatibility
10
17
 
11
- Additionally, `dynamic-modal` is compatible with **Next.js**.
18
+ According to `package.json`, this library is compatible with:
12
19
 
13
- ## Installation
20
+ - `react`: `^18.0.0 || ^19.0.0`
21
+ - `react-dom`: `^18.0.0 || ^19.0.0`
22
+ - `react-hook-form`: `^7.54.2`
23
+
24
+ The library itself is currently built with:
25
+
26
+ - `react`: `^19.0.0`
27
+ - `react-dom`: `^19.0.0`
28
+ - `react-hook-form`: `^7.54.2`
14
29
 
15
- Install the library via npm:
30
+ It works well in React apps and in Next.js projects that support client
31
+ components.
32
+
33
+ ## Installation
16
34
 
17
35
  ```bash
18
36
  npm install dynamic-modal
19
37
  ```
20
38
 
21
- ## Setup for Next.js (14 or 15)
22
- Define your components for use inside modal, create file and configure all modal components. Here´s an example using HeroUI (previously NextUI):
39
+ If your project does not already include the required peers, install them too:
23
40
 
24
- ```jsx
25
- 'use client'
26
- import { Autocomplete, AutocompleteItem, Button, Input, Select, SelectItem, Switch, Textarea } from "@heroui/react"
27
- import { IComponentState } from "dynamic-modal/src/interfaces/component-state"
41
+ ```bash
42
+ npm install react react-dom react-hook-form
43
+ ```
28
44
 
29
- export const ModalComponents: IComponentState = {
30
- ModalButtonCancel: (props) => {
31
- return (
32
- <Button
33
- {...props}
34
- color={props.color as "default" | "primary" | "secondary" | "success" | "warning" | "danger" | undefined}
35
- variant={'bordered'}
36
- >
37
- {props.text}
38
- </Button>
39
- )
40
- },
41
- ModalButtonAction: (props) => {
42
- return (
43
- <Button
44
- {...props}
45
- color={props.color as "default" | "primary" | "secondary" | "success" | "warning" | "danger" | undefined}
46
- variant={'solid'}
47
- >
48
- {props.text}
49
- </Button>)
50
- },
51
- Button: ({ text, ...props }) => {
52
- return (
53
- <Button
54
- {...props}
55
- color={props.color as "default" | "primary" | "secondary" | "success" | "warning" | "danger" | undefined}
56
- variant={props.variant as "flat" | "bordered" | "solid" | "light" | "faded" | "shadow" | "ghost" | undefined}
57
- >
58
- {text}
59
- </Button>)
60
- },
61
- Input: ({ invalid, error, disabled, onChange, ...props }) => {
62
- return (
63
- <Input
64
- {...props}
65
- onValueChange={onChange}
66
- errorMessage={error?.message}
67
- isInvalid={invalid}
68
- isDisabled={disabled}
69
- />
70
- )
71
- },
72
- Select: ({ options, invalid, error, isMulti, isSearch, disabled, onChange, value, ...props }) => {
73
- return (
74
- !isSearch ?
75
- <Select
45
+ ## Exports
46
+
47
+ The package exposes:
48
+
49
+ - `DynamicModal`
50
+ - `useModalHandler`
51
+ - `ComponentState`
52
+ - `ComponentStateContext`
53
+ - `IComponentState`
54
+ - `IModalConfigLoader`
55
+ - `IModalConfigProps`
56
+ - `IModalRenderCondition`
57
+ - `IModalField`
58
+ - `IModalLiveDataCondition`
59
+ - `IOption`
60
+
61
+ ## Mental model
62
+
63
+ You use the library in 4 steps:
64
+
65
+ 1. Define the UI components the modal should use in your app.
66
+ 2. Wrap your app with `ComponentState`.
67
+ 3. Render `DynamicModal` and control it with `useModalHandler`.
68
+ 4. Build modal configs as plain objects and open them when needed.
69
+
70
+ ## 1. Provide your own components
71
+
72
+ `dynamic-modal` does not force a UI kit on you.
73
+ You provide your own inputs, selects, buttons, toggles, and textarea components
74
+ through `ComponentState`, so the modal matches your app visually.
75
+
76
+ Example:
77
+
78
+ ```tsx
79
+ 'use client';
80
+
81
+ import { ReactNode } from 'react';
82
+ import {
83
+ Autocomplete,
84
+ AutocompleteItem,
85
+ Button,
86
+ Input,
87
+ Select,
88
+ SelectItem,
89
+ Switch,
90
+ Textarea,
91
+ } from '@heroui/react';
92
+ import type { IComponentState } from 'dynamic-modal';
93
+
94
+ export const modalComponents: IComponentState = {
95
+ ModalButtonCancel: ({ text, color, ...props }) => (
96
+ <Button {...props} color={color as any} variant="bordered">
97
+ {text}
98
+ </Button>
99
+ ),
100
+ ModalButtonAction: ({ text, color, ...props }) => (
101
+ <Button {...props} color={color as any} variant="solid">
102
+ {text}
103
+ </Button>
104
+ ),
105
+ Button: ({ text, color, variant, ...props }) => (
106
+ <Button {...props} color={color as any} variant={variant as any}>
107
+ {text}
108
+ </Button>
109
+ ),
110
+ Input: ({ invalid, error, disabled, onChange, value, ...props }) => (
111
+ <Input
112
+ {...props}
113
+ value={value ?? ''}
114
+ onValueChange={onChange}
115
+ errorMessage={error?.message}
116
+ isInvalid={invalid}
117
+ isDisabled={disabled}
118
+ />
119
+ ),
120
+ Select: ({
121
+ options,
122
+ invalid,
123
+ error,
124
+ isMulti,
125
+ isSearch,
126
+ disabled,
127
+ onChange,
128
+ value,
129
+ ...props
130
+ }) =>
131
+ isSearch ? (
132
+ <Autocomplete
76
133
  {...props}
77
- selectedKeys={isMulti ? value : [value]}
78
- onSelectionChange={onChange}
79
- selectionMode={isMulti ? 'multiple' : 'single'}
80
- errorMessage={error?.message}
81
- isInvalid={invalid}
82
- isDisabled={disabled}
83
- >
84
- {options.map((option) => (
85
- <SelectItem key={option.id}>{option.name}</SelectItem>
86
- ))}
87
- </Select> :
88
- <Autocomplete
89
- {...props}
90
134
  selectedKey={value}
91
- onSelectionChange={onChange}
135
+ onSelectionChange={onChange as any}
92
136
  errorMessage={error?.message}
93
137
  isInvalid={invalid}
94
138
  isDisabled={disabled}
95
- >
139
+ >
96
140
  {options.map((item) => (
97
141
  <AutocompleteItem key={item.id}>{item.name}</AutocompleteItem>
98
142
  ))}
99
143
  </Autocomplete>
100
- )
101
- },
102
- Textarea: ({ invalid, error, disabled, ...props }) => {
103
- return (
104
- <Textarea
144
+ ) : (
145
+ <Select
105
146
  {...props}
147
+ selectedKeys={isMulti ? (value ?? []) : value ? [value] : []}
148
+ onSelectionChange={onChange as any}
149
+ selectionMode={isMulti ? 'multiple' : 'single'}
106
150
  errorMessage={error?.message}
107
151
  isInvalid={invalid}
108
152
  isDisabled={disabled}
109
- />
110
- )
111
- },
112
- Toggle: ({ value, onChange, label, invalid, ...props }) => {
113
- return(
114
- <Switch {...props} isSelected={value} onValueChange={onChange}>
115
- {label}
116
- </Switch>
117
- )
118
- }
153
+ >
154
+ {options.map((option) => (
155
+ <SelectItem key={option.id}>{option.name}</SelectItem>
156
+ ))}
157
+ </Select>
158
+ ),
159
+ Textarea: ({ invalid, error, disabled, value, onChange, ...props }) => (
160
+ <Textarea
161
+ {...props}
162
+ value={value ?? ''}
163
+ onValueChange={onChange}
164
+ errorMessage={error?.message}
165
+ isInvalid={invalid}
166
+ isDisabled={disabled}
167
+ />
168
+ ),
169
+ Toggle: ({ value, onChange, label, ...props }) => (
170
+ <Switch {...props} isSelected={!!value} onValueChange={onChange}>
171
+ {label}
172
+ </Switch>
173
+ ),
174
+ };
175
+
176
+ export function ModalProvider({ children }: { children: ReactNode }) {
177
+ return <ComponentState components={modalComponents}>{children}</ComponentState>;
119
178
  }
120
179
  ```
121
180
 
122
- In the main provider of your React application, import your modal components (defined previously) and wrap your app with the `ComponentState` component to ensure `dynamic-modal` functions properly. Here’s an example:
181
+ ## 2. Add the provider and portal
123
182
 
124
- ```jsx
125
- import { ComponentState } from 'dynamic-modal'
126
- import { ModalComponents } from "@data/modal-component/modal-component"
183
+ Wrap your app with `ComponentState` and add a portal target with the id
184
+ `modal-portal`.
127
185
 
128
- function Provider({ children }: Readonly<{ children: ReactNode }>) {
129
- return (
130
- <ComponentState components={ModalComponents}>
131
- <Component {...pageProps} />
132
- </ComponentState>
133
- )
134
- }
135
-
136
- export default Provider
186
+ ### Next.js App Router
137
187
 
138
- ```
139
- In the root layout define `portal` for modal (this component use react portal)
188
+ ```tsx
189
+ import type { ReactNode } from 'react';
140
190
 
141
- ```jsx
142
- //imports...
143
-
144
- export default function RootLayout ({
145
- children
146
- }: Readonly<{
147
- children: ReactNode
148
- }>) {
191
+ export default function RootLayout({
192
+ children,
193
+ }: Readonly<{ children: ReactNode }>) {
149
194
  return (
150
195
  <html lang="en">
151
- <body className={inter.className}>
152
- <Provider>
153
- {children}
154
- </Provider>
155
- <div id='modal-portal'/>
196
+ <body>
197
+ <ModalProvider>{children}</ModalProvider>
198
+ <div id="modal-portal" />
156
199
  </body>
157
200
  </html>
158
- )
201
+ );
159
202
  }
160
203
  ```
161
204
 
162
- ## Setup for Next.js 13 and old
163
- Edit file named `_document.tsx` and define `portal` for modal (this component use react portal)
205
+ ### Next.js Pages Router
164
206
 
165
- ```jsx
166
- import { Html, Head, Main, NextScript } from 'next/document'
207
+ ```tsx
208
+ import { Html, Head, Main, NextScript } from 'next/document';
167
209
 
168
- export default function Document () {
210
+ export default function Document() {
169
211
  return (
170
212
  <Html>
171
213
  <Head />
172
214
  <body>
173
215
  <Main />
174
- <div id='modal-portal'/>
216
+ <div id="modal-portal" />
175
217
  <NextScript />
176
218
  </body>
177
219
  </Html>
178
- )
220
+ );
179
221
  }
180
222
  ```
181
223
 
182
- ## Usage
183
- To control the modal’s open and close states, use the `useModalHandler` custom hook and call `openModal` whenever you need to display the modal.
224
+ ## 3. Render and control the modal
184
225
 
185
- ```jsx
186
- import { useModalHandler, DynamicModal } from 'dynamic-modal'
187
- import { Button } from '@nextui-org/react'
188
- //Create your modal, import and use
189
- import testModal from '@modal-config/test'
226
+ Use `useModalHandler` to open the modal and render `DynamicModal` once in your
227
+ page or component tree.
190
228
 
191
- function ExampleComponent() {
192
- const { openModal, modalProps } = useModalHandler()
229
+ ```tsx
230
+ 'use client';
231
+
232
+ import { DynamicModal, useModalHandler } from 'dynamic-modal';
233
+ import { Button } from '@heroui/react';
234
+ import simpleModal from './modal-config/simple-modal';
235
+
236
+ export default function ExamplePage() {
237
+ const { openModal, modalProps } = useModalHandler();
193
238
 
194
239
  return (
195
240
  <>
196
241
  <Button
197
242
  onClick={() => {
198
- openModal(testModal.default({}, (data) => {
199
- console.log('modal data', data)
200
- }))
243
+ openModal(
244
+ simpleModal.default(
245
+ { reserved: 'abc', input1: 'Initial value', store: false },
246
+ (data) => {
247
+ console.log('modal result', data);
248
+ },
249
+ ),
250
+ );
201
251
  }}
202
252
  >
203
253
  Open modal
@@ -205,12 +255,394 @@ function ExampleComponent() {
205
255
 
206
256
  <DynamicModal {...modalProps} />
207
257
  </>
208
- )
258
+ );
259
+ }
260
+ ```
261
+
262
+ ## 4. Create modal configs
263
+
264
+ The recommended pattern is to define modal configs with `IModalConfigLoader`.
265
+ This lets you:
266
+
267
+ - receive input props
268
+ - return a typed modal config
269
+ - receive typed modal output in the `action` callback
270
+
271
+ Basic example:
272
+
273
+ ```ts
274
+ import type { IModalConfigLoader } from 'dynamic-modal';
275
+
276
+ type IncomingProps = {
277
+ reserved: string;
278
+ input1: string;
279
+ store?: boolean;
280
+ clear?: boolean;
281
+ };
282
+
283
+ type ResultProps = IncomingProps;
284
+
285
+ const simpleModal: {
286
+ default: IModalConfigLoader<IncomingProps, ResultProps>;
287
+ } = {
288
+ default: (props, action) => ({
289
+ reservedData: {
290
+ reserved: props.reserved,
291
+ },
292
+ title: 'Basic modal',
293
+ style: {
294
+ width: '500px',
295
+ },
296
+ fields: [
297
+ {
298
+ elementType: 'input',
299
+ label: 'Input 1',
300
+ name: 'input1',
301
+ defaultValue: props.input1,
302
+ validation: {
303
+ required: true,
304
+ message: 'This field is required',
305
+ },
306
+ },
307
+ {
308
+ elementType: 'group',
309
+ groups: [
310
+ {
311
+ elementType: 'toggle',
312
+ label: 'Store',
313
+ name: 'store',
314
+ defaultValue: `${props.store ?? false}`,
315
+ style: { width: '50%' },
316
+ validation: {
317
+ required: false,
318
+ },
319
+ },
320
+ {
321
+ elementType: 'toggle',
322
+ label: 'Clear',
323
+ name: 'clear',
324
+ defaultValue: `${props.clear ?? false}`,
325
+ style: { width: '50%' },
326
+ validation: {
327
+ required: false,
328
+ },
329
+ },
330
+ ],
331
+ },
332
+ ],
333
+ out: action,
334
+ actions: {
335
+ action: { text: 'Save', color: 'primary' },
336
+ cancel: { text: 'Cancel', color: 'danger' },
337
+ },
338
+ }),
339
+ };
340
+
341
+ export default simpleModal;
342
+ ```
343
+
344
+ ## Supported field types
345
+
346
+ You can build modal UIs with these field types:
347
+
348
+ - `input`
349
+ - `select`
350
+ - `textarea`
351
+ - `toggle`
352
+ - `text`
353
+ - `upload`
354
+ - `custom-upload`
355
+ - `watcher`
356
+ - `button`
357
+ - `table`
358
+ - `group`
359
+
360
+ `group` lets you place multiple fields in the same row.
361
+
362
+ ## Conditional behavior
363
+
364
+ One of the main strengths of the library is dynamic behavior based on form
365
+ state.
366
+
367
+ ### `renderIf`
368
+
369
+ Use `renderIf` when a field should appear only if another field matches one or
370
+ more values.
371
+
372
+ ```ts
373
+ {
374
+ elementType: 'input',
375
+ label: 'Company name',
376
+ name: 'companyName',
377
+ validation: {
378
+ required: true,
379
+ message: 'Write a company name',
380
+ },
381
+ renderIf: {
382
+ personType: ['company'],
383
+ },
384
+ }
385
+ ```
386
+
387
+ You can also use `'*'` as a wildcard:
388
+
389
+ ```ts
390
+ renderIf: {
391
+ personType: ['*'],
392
+ }
393
+ ```
394
+
395
+ ### `enableIf`
396
+
397
+ Use `enableIf` when a field should stay visible but only become editable if a
398
+ condition is met.
399
+
400
+ ```ts
401
+ {
402
+ elementType: 'input',
403
+ label: 'Discount code',
404
+ name: 'discountCode',
405
+ validation: {
406
+ required: false,
407
+ },
408
+ enableIf: {
409
+ hasDiscount: ['true'],
410
+ },
411
+ }
412
+ ```
413
+
414
+ ### `liveData`
415
+
416
+ Use `liveData` when one field depends on another and must fetch options
417
+ dynamically.
418
+
419
+ ```ts
420
+ {
421
+ elementType: 'select',
422
+ label: 'City',
423
+ name: 'cityId',
424
+ options: [],
425
+ validation: {
426
+ required: true,
427
+ message: 'Select a city',
428
+ },
429
+ liveData: {
430
+ condition: ['countryId'],
431
+ action: async (countryId, formData) => {
432
+ const response = await fetch(`/api/cities?countryId=${countryId}`);
433
+ const data = await response.json();
434
+
435
+ return data.map((city: { id: string; name: string }) => ({
436
+ id: city.id,
437
+ name: city.name,
438
+ }));
439
+ },
440
+ },
441
+ }
442
+ ```
443
+
444
+ ## Advanced conditions with async actions
445
+
446
+ `renderIf` and `enableIf` can also use async logic instead of static value maps.
447
+ This is useful if the decision depends on the backend or on custom business
448
+ rules.
449
+
450
+ Example:
451
+
452
+ ```ts
453
+ renderIf: {
454
+ condition: ['customerId'],
455
+ action: async (customerId, formData) => {
456
+ const response = await fetch(`/api/customers/${customerId}/can-edit`);
457
+ const data = await response.json();
458
+ return data.allowed;
459
+ },
460
+ }
461
+ ```
462
+
463
+ The same shape works for `enableIf`.
464
+
465
+ ## Examples by use case
466
+
467
+ ### 1. Basic modal
468
+
469
+ Use this when you just need a standard modal with fixed fields.
470
+
471
+ ```ts
472
+ fields: [
473
+ {
474
+ elementType: 'input',
475
+ label: 'Name',
476
+ name: 'name',
477
+ validation: { required: true, message: 'Required' },
478
+ },
479
+ {
480
+ elementType: 'textarea',
481
+ label: 'Description',
482
+ name: 'description',
483
+ validation: { required: false },
484
+ },
485
+ ];
486
+ ```
487
+
488
+ ### 2. Render fields depending on a select
489
+
490
+ Use `renderIf` for mutually exclusive sections.
491
+
492
+ ```ts
493
+ fields: [
494
+ {
495
+ elementType: 'select',
496
+ label: 'Mode',
497
+ name: 'mode',
498
+ defaultValue: 'email',
499
+ options: [
500
+ { id: 'email', name: 'Email' },
501
+ { id: 'sms', name: 'SMS' },
502
+ ],
503
+ validation: { required: true, message: 'Select a mode' },
504
+ },
505
+ {
506
+ elementType: 'input',
507
+ label: 'Email',
508
+ name: 'email',
509
+ validation: { required: true, message: 'Write an email' },
510
+ renderIf: { mode: ['email'] },
511
+ },
512
+ {
513
+ elementType: 'input',
514
+ label: 'Phone',
515
+ name: 'phone',
516
+ validation: { required: true, message: 'Write a phone' },
517
+ renderIf: { mode: ['sms'] },
518
+ },
519
+ ];
520
+ ```
521
+
522
+ ### 3. Keep the field visible but disabled
523
+
524
+ Use `enableIf` if the user should see the field before it becomes available.
525
+
526
+ ```ts
527
+ {
528
+ elementType: 'input',
529
+ label: 'Approval note',
530
+ name: 'approvalNote',
531
+ validation: { required: false },
532
+ enableIf: {
533
+ status: ['approved'],
534
+ },
209
535
  }
536
+ ```
537
+
538
+ ### 4. Load options from another field
210
539
 
211
- export default ExampleComponent
540
+ Use `liveData` for dependent selects.
212
541
 
542
+ ```ts
543
+ fields: [
544
+ {
545
+ elementType: 'select',
546
+ label: 'Type',
547
+ name: 'typeId',
548
+ options: props.typeList,
549
+ validation: {
550
+ required: true,
551
+ message: 'Please select a valid type',
552
+ },
553
+ },
554
+ {
555
+ elementType: 'select',
556
+ label: 'Options',
557
+ name: 'optionId',
558
+ options: [],
559
+ validation: {
560
+ required: true,
561
+ message: 'Please select a valid option',
562
+ },
563
+ liveData: {
564
+ condition: ['typeId'],
565
+ action: props.optionReadAction,
566
+ },
567
+ },
568
+ ];
213
569
  ```
214
570
 
215
- ## Examples
216
- The examples folder in the repository contains different configuration modes to help you customize your modal.
571
+ ### 5. Reserve data that should travel with the result
572
+
573
+ Use `reservedData` when you want to preserve contextual information without
574
+ showing it in the modal.
575
+
576
+ ```ts
577
+ reservedData: {
578
+ customerId: props.customerId,
579
+ source: 'customer-profile',
580
+ }
581
+ ```
582
+
583
+ That data will be merged into the object returned by `out`.
584
+
585
+ ## Configuration reference
586
+
587
+ ### Modal-level config
588
+
589
+ Common properties of `IModalConfigProps`:
590
+
591
+ - `title`: modal title
592
+ - `fields`: list of modal elements
593
+ - `out`: callback invoked on submit
594
+ - `reservedData`: extra data merged into the result
595
+ - `onClose`: callback when the modal closes
596
+ - `style`: styles for the modal container
597
+ - `overFlowBody`: body height/overflow control
598
+ - `minHeightBody`: minimum body height
599
+ - `useSubmit`: if `false`, action button uses manual validation mode
600
+ - `useBlur`: enables backdrop blur style
601
+ - `actions.action`: main action button props
602
+ - `actions.cancel`: optional cancel button props
603
+ - `actions.containerStyle`: style for the action buttons container
604
+
605
+ ### Common field properties
606
+
607
+ Most form fields share:
608
+
609
+ - `name`
610
+ - `label`
611
+ - `placeholder`
612
+ - `defaultValue`
613
+ - `style`
614
+ - `disabled`
615
+ - `validation`
616
+ - `renderIf`
617
+ - `enableIf`
618
+
619
+ Validation supports:
620
+
621
+ - `required`
622
+ - `message`
623
+ - `regex`
624
+ - `maxLength`
625
+ - `minLength`
626
+ - `min`
627
+ - `max`
628
+
629
+ ## Notes and recommendations
630
+
631
+ - Render `DynamicModal` only once per screen or page branch when possible.
632
+ - Prefer stable `name` values because they are used to manage form state.
633
+ - Use `renderIf` for hidden sections and `enableIf` for visible-but-locked
634
+ sections.
635
+ - Keep `liveData` actions fast and deterministic when possible.
636
+ - If your custom UI components use different event contracts, adapt them inside
637
+ `ComponentState` rather than changing modal configs.
638
+
639
+ ## Repository examples
640
+
641
+ This repository includes working examples in:
642
+
643
+ - `examples/simple.ts`
644
+ - `examples/render-if.ts`
645
+ - `examples/enable-if.ts`
646
+ - `examples/live-data.ts`
647
+
648
+ These are useful starting points for building your own modal catalog.