oihana-next-ui 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oihana-next-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Oihana Next.js UI component library — reusable components, hooks and utilities built with React 19, Next.js, Tailwind CSS and DaisyUI",
6
6
  "author": {
@@ -19,73 +19,185 @@ import
19
19
  }
20
20
  from '../../themes/components/modal' ;
21
21
 
22
- const Modal =
23
- ({
24
- // Header
25
- closeClassName ,
26
- closeIcon= <CloseIcon size={20}/>,
27
- closeTitle = 'Close' ,
28
- title,
29
- icon,
30
- showHeader = true,
31
- showTitle = true,
32
- showIcon = true,
33
- showCloseButton = true,
34
- headerClassName,
35
- headerOptions,
36
-
37
- // Footer
38
- agree = 'OK',
39
- disagree = 'Cancel',
40
- agreeColor = 'primary',
41
- disagreeColor = 'neutral',
42
- agreeIcon,
43
- disagreeIcon,
44
- agreeDisabled,
45
- disagreeDisabled,
46
- showFooter = true,
47
- showAgree = true,
48
- showDisagree = true,
49
- footerReverse = false,
50
- footerClassName,
51
- footerOptions,
52
- onAgree,
53
- onCancel,
54
-
55
- // Layout
56
- placement = 'middle',
57
- responsivePlacement,
58
- maxWidth = 'max-w-2xl',
59
- fullScreen,
60
- fullScreenBreakpoint,
61
- fullWidth,
62
-
63
- // Backdrop
64
- showBackdrop = true,
65
- disableBackdropClick = false,
66
- backdropClassName,
67
-
68
- // Behavior
69
- disableEscapeKeyDown = false,
70
- disabled,
71
- onClose,
72
-
73
- // Content
74
- children,
75
-
76
- // Styling
77
- className,
78
- modalBoxClassName,
79
- contentClassName,
80
- titleClassName,
81
-
82
- // Ref
83
- ref,
84
- }) =>
22
+ const FOOTER_NODE_OVERRIDE_PROPS =
23
+ [
24
+ 'agree',
25
+ 'disagree',
26
+ 'agreeColor',
27
+ 'disagreeColor',
28
+ 'agreeIcon',
29
+ 'disagreeIcon',
30
+ 'agreeDisabled',
31
+ 'disagreeDisabled',
32
+ 'showAgree',
33
+ 'showDisagree',
34
+ 'showFooter',
35
+ 'footerReverse',
36
+ 'footerClassName',
37
+ 'footerOptions',
38
+ 'onAgree',
39
+ 'onCancel',
40
+ ] ;
41
+
42
+ /**
43
+ * Accessible dialog primitive built on top of the native `<dialog>` element and DaisyUI's `modal` styles.
44
+ *
45
+ * Two layout modes:
46
+ *
47
+ * 1. **Standard mode** (default) — header (sticky top), free-flow content area, optional footer
48
+ * rendered as a `modal-action` row with `agree` / `disagree` buttons (sticky bottom). The whole
49
+ * modal-box is the scroll container, header and footer stick to its edges as the user scrolls.
50
+ *
51
+ * 2. **Custom-footer mode** — activated by passing a `footerNode`. The modal-box becomes a vertical
52
+ * flex column: the content area grows and scrolls internally, while `footerNode` is rendered
53
+ * in a dedicated, non-scrollable, border-topped slot at the bottom. Use this for forms with
54
+ * a status text + custom action buttons, or any case where the standard footer is too rigid.
55
+ *
56
+ * When `footerNode` is provided, the standard footer (`agree`, `disagree`, `footerOptions`,
57
+ * `showFooter`, `footerReverse`, `footerClassName`, `onAgree`, `onCancel`, etc.) is **fully
58
+ * ignored** — `footerNode` wins. A `console.warn` is emitted in development if overlapping
59
+ * props are passed alongside.
60
+ *
61
+ * @example Standard usage with agree / disagree
62
+ * ```jsx
63
+ * <Modal
64
+ * ref = { modalRef }
65
+ * title = "Save changes?"
66
+ * agree = "Save"
67
+ * disagree = "Cancel"
68
+ * onAgree = { handleSave }
69
+ * >
70
+ * <p>Your unsaved changes will be lost.</p>
71
+ * </Modal>
72
+ * ```
73
+ *
74
+ * @example Custom footer with scrollable form (no `!important` overrides)
75
+ * ```jsx
76
+ * <Modal
77
+ * ref = { modalRef }
78
+ * title = "Edit profile"
79
+ * footerNode = {
80
+ * <div className="flex items-center gap-3 px-4 py-3">
81
+ * <span className="text-sm text-base-content/60">Saved 2s ago</span>
82
+ * <div className="ml-auto flex gap-2">
83
+ * <Button color="neutral" onClick={ handleCancel }>Cancel</Button>
84
+ * <Button color="primary" onClick={ handleSave }>Save</Button>
85
+ * </div>
86
+ * </div>
87
+ * }
88
+ * >
89
+ * <form className="flex flex-col gap-4">
90
+ * { /* many fields, the form scrolls — footer stays visible *\/ }
91
+ * </form>
92
+ * </Modal>
93
+ * ```
94
+ *
95
+ * @param {Object} props
96
+ * @param {React.ReactNode} [props.title] - Modal title (rendered in the header).
97
+ * @param {React.ReactNode} [props.icon] - Icon shown left of the title.
98
+ * @param {React.ReactNode} [props.headerOptions] - Extra nodes injected in the header row.
99
+ * @param {React.ReactNode} [props.footerOptions] - Extra nodes rendered alongside agree/disagree (standard mode only).
100
+ * @param {React.ReactNode} [props.footerNode] - **Custom footer** that fully replaces the standard footer. Activates the flex-column layout (sticky footer + internal content scroll). When set, all `agree*`/`disagree*`/`footer*`/`onAgree`/`onCancel`/`showFooter` props are ignored.
101
+ * @param {React.ReactNode} [props.children] - Modal body content.
102
+ * @param {string} [props.maxWidth='max-w-2xl'] - Tailwind max-width class for the modal-box.
103
+ * @param {boolean} [props.fullScreen] - Force full-screen modal.
104
+ * @param {string} [props.fullScreenBreakpoint] - Tailwind breakpoint below which the modal becomes full-screen (e.g. `'md'`).
105
+ * @param {string} [props.placement='middle'] - `'top'` | `'middle'` | `'bottom'` | `'start'` | `'end'`.
106
+ * @param {string} [props.responsivePlacement] - Responsive placement (e.g. `'sm:modal-middle'`).
107
+ * @param {boolean} [props.disableBackdropClick=false] - Prevent close on backdrop click.
108
+ * @param {boolean} [props.disableEscapeKeyDown=false] - Prevent close on `Escape`.
109
+ * @param {string} [props.contentClassName] - Extra classes on the content wrapper. In custom-footer mode, the default is `flex-1 min-h-0 overflow-y-auto p-2 py-4`; in standard mode, `overflow-y-auto h-full p-2 py-4`.
110
+ * @param {string} [props.modalBoxClassName] - Extra classes on the modal-box.
111
+ *
112
+ * @see https://daisyui.com/components/modal
113
+ */
114
+ const Modal = ( props ) =>
85
115
  {
116
+ const
117
+ {
118
+ // Header
119
+ closeClassName ,
120
+ closeIcon= <CloseIcon size={20}/>,
121
+ closeTitle = 'Close' ,
122
+ title,
123
+ icon,
124
+ showHeader = true,
125
+ showTitle = true,
126
+ showIcon = true,
127
+ showCloseButton = true,
128
+ headerClassName,
129
+ headerOptions,
130
+
131
+ // Footer (standard mode)
132
+ agree = 'OK',
133
+ disagree = 'Cancel',
134
+ agreeColor = 'primary',
135
+ disagreeColor = 'neutral',
136
+ agreeIcon,
137
+ disagreeIcon,
138
+ agreeDisabled,
139
+ disagreeDisabled,
140
+ showFooter = true,
141
+ showAgree = true,
142
+ showDisagree = true,
143
+ footerReverse = false,
144
+ footerClassName,
145
+ footerOptions,
146
+ onAgree,
147
+ onCancel,
148
+
149
+ // Footer (custom mode)
150
+ footerNode,
151
+
152
+ // Layout
153
+ placement = 'middle',
154
+ responsivePlacement,
155
+ maxWidth = 'max-w-2xl',
156
+ fullScreen,
157
+ fullScreenBreakpoint,
158
+ fullWidth,
159
+
160
+ // Backdrop
161
+ showBackdrop = true,
162
+ disableBackdropClick = false,
163
+ backdropClassName,
164
+
165
+ // Behavior
166
+ disableEscapeKeyDown = false,
167
+ disabled,
168
+ onClose,
169
+
170
+ // Content
171
+ children,
172
+
173
+ // Styling
174
+ className,
175
+ modalBoxClassName,
176
+ contentClassName,
177
+ titleClassName,
178
+
179
+ // Ref
180
+ ref,
181
+ }
182
+ = props ;
183
+
86
184
  const dialogRef = useRef( null ) ;
87
185
  const titleId = useId() ;
88
186
 
187
+ const hasCustomFooter = footerNode !== undefined && footerNode !== null ;
188
+
189
+ if ( process.env.NODE_ENV !== 'production' && hasCustomFooter )
190
+ {
191
+ const overlap = FOOTER_NODE_OVERRIDE_PROPS.filter( key => key in props && props[ key ] !== undefined ) ;
192
+ if ( overlap.length > 0 )
193
+ {
194
+ console.warn(
195
+ `[Modal] \`footerNode\` is set, the following overlapping prop(s) are ignored: ${ overlap.join( ', ' ) }. ` +
196
+ `Move this content into \`footerNode\` itself.`
197
+ ) ;
198
+ }
199
+ }
200
+
89
201
  const isAboveBreakpoint = fullScreenBreakpoint
90
202
  ? useBreakpoint( fullScreenBreakpoint )
91
203
  : true ;
@@ -165,6 +277,7 @@ const Modal =
165
277
  fullScreen : isFullScreen,
166
278
  fullWidth,
167
279
  placement : responsivePlacement || placement,
280
+ flexLayout : hasCustomFooter,
168
281
  className : modalBoxClassName,
169
282
  }) ;
170
283
 
@@ -177,6 +290,14 @@ const Modal =
177
290
  className : backdropClassName,
178
291
  }) ;
179
292
 
293
+ const contentClasses = hasCustomFooter
294
+ ? cn( 'flex-1 min-h-0 overflow-y-auto p-2 py-4' , contentClassName )
295
+ : cn( 'overflow-y-auto h-full p-2 py-4' , contentClassName ) ;
296
+
297
+ const headerClasses = hasCustomFooter
298
+ ? cn( 'shrink-0 bg-base-100 border-b border-base-300/60 z-10 p-2 pb-3' , headerClassName )
299
+ : cn( 'sticky top-0 bg-base-100 border-b border-base-300/60 z-10 p-2 pb-3' , headerClassName ) ;
300
+
180
301
  return (
181
302
  <dialog
182
303
  aria-labelledby = { showTitle && title ? titleId : undefined }
@@ -197,7 +318,7 @@ const Modal =
197
318
  <div className={ modalBoxClasses }>
198
319
 
199
320
  { showHeader && (
200
- <div className={`sticky top-0 bg-base-100 border-b border-base-300/60 z-10 p-2 pb-3 ${ headerClassName || '' }`}>
321
+ <div className={ headerClasses }>
201
322
  <div className="flex items-center gap-3">
202
323
 
203
324
  { showIcon && icon && (
@@ -232,11 +353,15 @@ const Modal =
232
353
  </div>
233
354
  )}
234
355
 
235
- <div className={ cn( 'overflow-y-auto h-full p-2 py-4' , contentClassName ) }>
356
+ <div className={ contentClasses }>
236
357
  { children }
237
358
  </div>
238
359
 
239
- { showFooter && (
360
+ { hasCustomFooter ? (
361
+ <div className="shrink-0 bg-base-100 border-t border-base-300/60">
362
+ { footerNode }
363
+ </div>
364
+ ) : showFooter && (
240
365
  <div className={`sticky bottom-0 bg-base-100 border-t border-base-300/60 p-0 ${ footerClassName || '' }`}>
241
366
 
242
367
  <div className={ modalActionClasses }>
@@ -274,4 +399,4 @@ const Modal =
274
399
 
275
400
  Modal.displayName = 'Modal' ;
276
401
 
277
- export default Modal ;
402
+ export default Modal ;
@@ -22,7 +22,8 @@ import {
22
22
  MdError,
23
23
  MdDelete,
24
24
  MdDriveFileRenameOutline ,
25
- MdSave
25
+ MdSave,
26
+ MdCloudDone,
26
27
  } from 'react-icons/md' ;
27
28
 
28
29
  const ModalDemo = () =>
@@ -61,6 +62,9 @@ const ModalDemo = () =>
61
62
  // Form modal
62
63
  const { modalRef: formRef, open: openForm } = useModal() ;
63
64
 
65
+ // Custom footerNode modal (sticky footer + scrollable content)
66
+ const { modalRef: footerNodeRef, open: openFooterNode } = useModal() ;
67
+
64
68
  // Toggle demo
65
69
  const { modalRef: toggleRef, toggle: toggleModal, isOpen: toggleIsOpen } = useModal({
66
70
  onClose: () => console.log( 'Modal closed' ),
@@ -618,6 +622,162 @@ const ModalDemo = () =>
618
622
 
619
623
  <Divider />
620
624
 
625
+ {/* Custom Footer Node (sticky footer + scrollable content) */}
626
+ <div className="flex flex-col gap-4">
627
+ <h3 className="text-xl font-semibold border-b-2 border-info pb-2">
628
+ Custom Footer Node — sticky footer + scrollable content
629
+ </h3>
630
+
631
+ <div className="card bg-base-100 shadow">
632
+ <div className="card-body gap-4">
633
+
634
+ <h4 className="card-title text-base">
635
+ <MdInfo className="text-info" /> When to use <code className="badge badge-sm">footerNode</code>
636
+ </h4>
637
+
638
+ <p className="text-sm text-base-content/80">
639
+ Use the <code className="badge badge-sm">footerNode</code> prop when the standard
640
+ <code className="badge badge-sm">agree</code> / <code className="badge badge-sm">disagree</code> footer
641
+ is too rigid — typically for forms with a status text, custom buttons, or any layout
642
+ that does not fit the default <code className="badge badge-sm">modal-action</code> row.
643
+ </p>
644
+
645
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
646
+ <div>
647
+ <h5 className="font-bold text-success mb-2">✅ What it gives you</h5>
648
+ <ul className="list-disc list-inside space-y-1 text-sm">
649
+ <li>Footer always pinned at the bottom of the modal-box</li>
650
+ <li>Content area scrolls on its own (smooth, internal)</li>
651
+ <li>Header stays at the top</li>
652
+ <li>No need for <code className="badge badge-sm">!important</code> overrides</li>
653
+ <li>No need for <code className="badge badge-sm">modalBoxClassName="flex flex-col"</code> boilerplate</li>
654
+ </ul>
655
+ </div>
656
+
657
+ <div>
658
+ <h5 className="font-bold text-warning mb-2">⚠️ Precedence rules</h5>
659
+ <p className="text-sm mb-1">
660
+ When <code className="badge badge-sm">footerNode</code> is set, these props are <strong>ignored</strong>:
661
+ </p>
662
+ <p className="text-xs font-mono text-base-content/70">
663
+ agree, disagree, agreeColor, disagreeColor, agreeIcon, disagreeIcon,
664
+ showAgree, showDisagree, showFooter, footerReverse, footerClassName,
665
+ footerOptions, onAgree, onCancel
666
+ </p>
667
+ <p className="text-sm mt-2">
668
+ A <code className="badge badge-sm">console.warn</code> is emitted in dev if any of them
669
+ are passed alongside.
670
+ </p>
671
+ </div>
672
+ </div>
673
+
674
+ <div className="alert alert-info">
675
+ <MdInfo size={20} />
676
+ <div className="text-sm">
677
+ Standard mode (without <code>footerNode</code>) is unchanged: the existing
678
+ <code className="badge badge-sm">showFooter</code> behaviour with sticky agree/disagree row
679
+ still works exactly as before.
680
+ </div>
681
+ </div>
682
+
683
+ <h5 className="font-bold mt-2">Before / After</h5>
684
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
685
+ <div>
686
+ <p className="text-xs text-error mb-1 font-semibold">❌ Before (manual recipe — 8 lines, 5 ! markers)</p>
687
+ <div className="mockup-code text-xs">
688
+ <pre data-prefix="1"><code>&lt;Modal</code></pre>
689
+ <pre data-prefix="2"><code> contentClassName ="!overflow-hidden !p-0 flex flex-col flex-1 min-h-0"</code></pre>
690
+ <pre data-prefix="3"><code> modalBoxClassName="!overflow-hidden flex flex-col"</code></pre>
691
+ <pre data-prefix="4"><code> showFooter={`{false}`}&gt;</code></pre>
692
+ <pre data-prefix="5"><code> &lt;div className="flex-1 min-h-0 overflow-y-auto ..."&gt;</code></pre>
693
+ <pre data-prefix="6"><code> {`{form fields}`}</code></pre>
694
+ <pre data-prefix="7"><code> &lt;/div&gt;</code></pre>
695
+ <pre data-prefix="8"><code> &lt;div className="shrink-0 flex border-t bg-base-100 ..."&gt;</code></pre>
696
+ <pre data-prefix="9"><code> {`{status + cancel + save}`}</code></pre>
697
+ <pre data-prefix="10"><code> &lt;/div&gt;</code></pre>
698
+ <pre data-prefix="11"><code>&lt;/Modal&gt;</code></pre>
699
+ </div>
700
+ </div>
701
+
702
+ <div>
703
+ <p className="text-xs text-success mb-1 font-semibold">✅ After — 1 prop, no overrides</p>
704
+ <div className="mockup-code text-xs">
705
+ <pre data-prefix="1"><code>&lt;Modal</code></pre>
706
+ <pre data-prefix="2"><code> title="Edit profile"</code></pre>
707
+ <pre data-prefix="3"><code> footerNode={`{<FormFooter ... />}`}</code></pre>
708
+ <pre data-prefix="4"><code>&gt;</code></pre>
709
+ <pre data-prefix="5"><code> &lt;form className="flex flex-col gap-4"&gt;</code></pre>
710
+ <pre data-prefix="6"><code> {`{form fields}`}</code></pre>
711
+ <pre data-prefix="7"><code> &lt;/form&gt;</code></pre>
712
+ <pre data-prefix="8"><code>&lt;/Modal&gt;</code></pre>
713
+ <pre data-prefix="9"><code></code></pre>
714
+ <pre data-prefix="10"><code>// modal-box auto: flex flex-col</code></pre>
715
+ <pre data-prefix="11"><code>// content auto: flex-1 min-h-0 overflow-y-auto</code></pre>
716
+ </div>
717
+ </div>
718
+ </div>
719
+
720
+ </div>
721
+ </div>
722
+
723
+ <Button color="info" onClick={ openFooterNode }>
724
+ Open Modal with footerNode + long form
725
+ </Button>
726
+
727
+ <Modal
728
+ ref = { footerNodeRef }
729
+ title = "Edit User Profile (long form)"
730
+ icon = { <MdDriveFileRenameOutline size={24} className="text-info" /> }
731
+ maxWidth = "max-w-xl"
732
+ footerNode = {
733
+ <div className="flex items-center gap-3 px-4 py-3">
734
+ <div className="flex items-center gap-2 text-sm text-base-content/70">
735
+ <MdCloudDone className="text-success" size={18} />
736
+ <span>Saved 2 seconds ago</span>
737
+ </div>
738
+ <div className="ml-auto flex gap-2">
739
+ <Button
740
+ color = "neutral"
741
+ size = "sm"
742
+ onClick = { () => footerNodeRef.current?.close() }
743
+ >
744
+ Cancel
745
+ </Button>
746
+ <Button
747
+ color = "primary"
748
+ size = "sm"
749
+ onClick = { () =>
750
+ {
751
+ console.log( 'Profile saved' ) ;
752
+ footerNodeRef.current?.close() ;
753
+ }}
754
+ >
755
+ <MdSave size={16} />
756
+ Save
757
+ </Button>
758
+ </div>
759
+ </div>
760
+ }
761
+ >
762
+ <div className="flex flex-col gap-4 px-2">
763
+ <p className="text-sm text-base-content/70">
764
+ Scroll inside this modal — notice that the header stays at the top
765
+ and the footer stays visible at the bottom while the form scrolls.
766
+ </p>
767
+
768
+ { Array.from( { length: 25 } ).map( ( _ , i ) => (
769
+ <Input
770
+ key = { i }
771
+ label = { `Field ${ i + 1 }` }
772
+ placeholder = { `Enter value for field ${ i + 1 }` }
773
+ />
774
+ ))}
775
+ </div>
776
+ </Modal>
777
+ </div>
778
+
779
+ <Divider />
780
+
621
781
  {/* Form Example */}
622
782
  <div className="flex flex-col gap-4">
623
783
  <h3 className="text-xl font-semibold border-b-2 border-secondary pb-2">
@@ -62,6 +62,7 @@ export const getModalClasses =
62
62
  * @param {boolean} [props.fullScreen] - Full screen mode
63
63
  * @param {boolean} [props.fullWidth] - Full width mode
64
64
  * @param {string} [props.placement] - Modal placement (for centering logic)
65
+ * @param {boolean} [props.flexLayout] - Switch the modal-box to a vertical flex column so a sticky custom footer + scrollable content area can be composed cleanly. Used by `<Modal footerNode>`.
65
66
  * @param {string} [props.className] - Additional classes
66
67
  *
67
68
  * @returns {string} Combined class names
@@ -69,6 +70,7 @@ export const getModalClasses =
69
70
  export const getModalBoxClasses =
70
71
  ({
71
72
  className ,
73
+ flexLayout ,
72
74
  fullScreen ,
73
75
  fullWidth ,
74
76
  maxWidth,
@@ -77,11 +79,13 @@ export const getModalBoxClasses =
77
79
  = {} ) => cn
78
80
  (
79
81
  MODAL_BOX ,
82
+ 'px-4 pt-1 pb-3',
80
83
  {
81
84
  'max-w-none w-full max-h-none h-full rounded-none' : fullScreen ,
82
85
  'w-full max-w-none' : !fullScreen && fullWidth ,
83
86
  'mx-auto' : !fullScreen && !fullWidth && ( placement === 'top' || placement === 'bottom' ) ,
84
87
  [ maxWidth ] : !fullScreen && !fullWidth && maxWidth ,
88
+ 'flex flex-col overflow-hidden' : flexLayout ,
85
89
  },
86
90
  className,
87
91
  ) ;
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
- const version = "0.2.0" ;
1
+ const version = "0.2.1" ;
2
2
 
3
3
  export default version ;