oihana-next-ui 0.2.0 → 0.2.2
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 +1 -1
- package/src/@configs/navigation.js +8 -6
- package/src/@locale/navigation.js +2 -0
- package/src/app/lab/@tabs/infiniteScroll/page.js +26 -0
- package/src/components/layouts/InfiniteScroll.jsx +172 -0
- package/src/components/modals/Modal.jsx +192 -67
- package/src/demo/layouts/InfiniteScrollDemo.jsx +343 -0
- package/src/demo/modals/ModalDemo.jsx +161 -1
- package/src/helpers/react/resolveRefElement.js +26 -0
- package/src/hooks/useInfiniteScroll.js +100 -0
- package/src/themes/components/modal.js +4 -0
- package/src/version.js +1 -1
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ import { IoMdGrid as GridIcon } from "react-icons/io";
|
|
|
23
23
|
import { FaPager as HeaderIcon } from "react-icons/fa6";
|
|
24
24
|
import { SlPicture as ImageIcon } from "react-icons/sl";
|
|
25
25
|
import { RiInputField as InputIcon } from "react-icons/ri" ;
|
|
26
|
+
import { TbInfinity as InfiniteScrollIcon } from "react-icons/tb";
|
|
26
27
|
import { LuLayoutDashboard as LayoutIcon } from "react-icons/lu";
|
|
27
28
|
import { CiBoxList as ListIcon } from "react-icons/ci";
|
|
28
29
|
import { RiProgress7Line as LoadingIcon } from "react-icons/ri";
|
|
@@ -73,12 +74,13 @@ const navigation =
|
|
|
73
74
|
Icon : LayoutIcon ,
|
|
74
75
|
items :
|
|
75
76
|
[
|
|
76
|
-
{ id : 'flex'
|
|
77
|
-
{ id : 'grid'
|
|
78
|
-
{ id : 'masonry'
|
|
79
|
-
{ id : '
|
|
80
|
-
{ id : '
|
|
81
|
-
{ id : '
|
|
77
|
+
{ id : 'flex' , type : LINK , Icon : FlexIcon , path : '/lab/flex' } ,
|
|
78
|
+
{ id : 'grid' , type : LINK , Icon : GridIcon , path : '/lab/grid' } ,
|
|
79
|
+
{ id : 'masonry' , type : LINK , Icon : MasonryIcon , path : '/lab/masonry' } ,
|
|
80
|
+
{ id : 'infiniteScroll' , type : LINK , Icon : InfiniteScrollIcon , path : '/lab/infiniteScroll' } ,
|
|
81
|
+
{ id : 'layout' , type : LINK , Icon : LayoutIcon , path : '/lab/layout' } ,
|
|
82
|
+
{ id : 'collapse' , type : LINK , Icon : CollapseIcon , path : '/lab/collapse' } ,
|
|
83
|
+
{ id : 'patterns' , type : LINK , Icon : PatternsIcon , path : '/lab/patterns' } ,
|
|
82
84
|
]
|
|
83
85
|
} ,
|
|
84
86
|
{
|
|
@@ -25,6 +25,7 @@ const navigation =
|
|
|
25
25
|
grid : 'Grid' ,
|
|
26
26
|
layout : 'Layout' ,
|
|
27
27
|
masonry : 'Masonry' ,
|
|
28
|
+
infiniteScroll : 'Scroll infini' ,
|
|
28
29
|
table : 'Table' ,
|
|
29
30
|
feedback : 'Feedback' ,
|
|
30
31
|
loading : 'Loading' ,
|
|
@@ -73,6 +74,7 @@ const navigation =
|
|
|
73
74
|
grid : 'Grid' ,
|
|
74
75
|
layout : 'Layout' ,
|
|
75
76
|
masonry : 'Masonry' ,
|
|
77
|
+
infiniteScroll : 'Infinite Scroll' ,
|
|
76
78
|
table : 'Table' ,
|
|
77
79
|
feedback : 'Feedback' ,
|
|
78
80
|
loading : 'Loading' ,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
3
|
+
import Page from '@/display/Page' ;
|
|
4
|
+
import Container from '@/display/Container' ;
|
|
5
|
+
import InfiniteScrollDemo from '@/demo/layouts/InfiniteScrollDemo' ;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* InfiniteScroll showcase page.
|
|
9
|
+
*/
|
|
10
|
+
const InfiniteScrollPage = () =>
|
|
11
|
+
(
|
|
12
|
+
<Page className="gap-8" full>
|
|
13
|
+
{/* Header */}
|
|
14
|
+
<Container className="text-center" maxWidth="max-w-4xl">
|
|
15
|
+
<h1 className="text-4xl font-bold bg-linear-to-r from-secondary to-primary inline-block text-transparent bg-clip-text">
|
|
16
|
+
Infinite Scroll
|
|
17
|
+
</h1>
|
|
18
|
+
</Container>
|
|
19
|
+
|
|
20
|
+
<Container maxWidth="max-w-3xl">
|
|
21
|
+
<InfiniteScrollDemo />
|
|
22
|
+
</Container>
|
|
23
|
+
</Page>
|
|
24
|
+
) ;
|
|
25
|
+
|
|
26
|
+
export default InfiniteScrollPage ;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react' ;
|
|
4
|
+
|
|
5
|
+
import Loading from '@/components/Loading' ;
|
|
6
|
+
|
|
7
|
+
import useInfiniteScroll from '@/hooks/useInfiniteScroll' ;
|
|
8
|
+
|
|
9
|
+
import cn from '@/themes/helpers/cn' ;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default root element type for the InfiniteScroll container.
|
|
13
|
+
* @type {string}
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_ELEMENT = 'div' ;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Class applied to the container when `scrollable` is enabled.
|
|
19
|
+
* @type {string}
|
|
20
|
+
*/
|
|
21
|
+
export const SCROLLABLE_CLASS = 'overflow-y-auto' ;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Class applied to the (visually empty) sentinel element.
|
|
25
|
+
* @type {string}
|
|
26
|
+
*/
|
|
27
|
+
export const SENTINEL_CLASS = 'h-px w-full' ;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Class applied to the default loading indicator wrapper.
|
|
31
|
+
* @type {string}
|
|
32
|
+
*/
|
|
33
|
+
export const LOADER_WRAPPER_CLASS = 'flex justify-center py-4' ;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Class applied to the container in `reverse` mode (chat-like). Lays children
|
|
37
|
+
* out bottom-to-top so the scroll stays anchored at the bottom : prepending
|
|
38
|
+
* older items does not shift the visible content.
|
|
39
|
+
* @type {string}
|
|
40
|
+
*/
|
|
41
|
+
export const REVERSE_CLASS = 'flex flex-col-reverse' ;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* InfiniteScroll — renders its children, then watches a sentinel element to
|
|
45
|
+
* load more content when the user scrolls near the edge of the list.
|
|
46
|
+
*
|
|
47
|
+
* Headless by design : it does not render the list itself, it wraps whatever
|
|
48
|
+
* you pass as `children` and manages the sentinel plus the loading / end
|
|
49
|
+
* indicators. Loading is paused while `loading` is true and stops once
|
|
50
|
+
* `hasMore` becomes false.
|
|
51
|
+
*
|
|
52
|
+
* @module components/layouts/InfiniteScroll
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} props
|
|
55
|
+
* @param {React.ElementType} [props.as='div'] - HTML element or component to render as the container.
|
|
56
|
+
* @param {React.ReactNode} [props.children] - The already-loaded content.
|
|
57
|
+
* @param {string} [props.className] - Additional class names for the container.
|
|
58
|
+
* @param {React.ReactNode} [props.endMessage=null] - Shown when `hasMore` is false.
|
|
59
|
+
* @param {boolean} [props.hasMore=true] - Whether more items can be loaded.
|
|
60
|
+
* @param {React.ReactNode} [props.loader] - Custom loading indicator (defaults to `<Loading />`).
|
|
61
|
+
* @param {boolean} [props.loading=false] - Loading-in-flight state.
|
|
62
|
+
* @param {Function} [props.onLoadMore] - Called when the sentinel is reached. Should be stable (`useCallback`).
|
|
63
|
+
* @param {boolean} [props.reverse=false] - Reverse / chat mode : lays children out bottom-to-top (`flex flex-col-reverse`) and loads older items when scrolling up. Children must be ordered newest-first.
|
|
64
|
+
* @param {string} [props.rootMargin] - Pre-load distance before the sentinel is reached.
|
|
65
|
+
* @param {boolean} [props.scrollable=false] - When true, the container is the scroll viewport and the observer root.
|
|
66
|
+
* @param {number} [props.threshold] - Visibility threshold (0-1).
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```jsx
|
|
70
|
+
* // Forward infinite scroll over a List
|
|
71
|
+
* <InfiniteScroll
|
|
72
|
+
* hasMore={ hasMore }
|
|
73
|
+
* loading={ loading }
|
|
74
|
+
* onLoadMore={ loadNextPage }
|
|
75
|
+
* endMessage={ <p className="text-center opacity-60 py-4">No more items</p> }
|
|
76
|
+
* >
|
|
77
|
+
* <List>
|
|
78
|
+
* { items.map( item => <ListRow key={ item.id } title={ item.title } /> ) }
|
|
79
|
+
* </List>
|
|
80
|
+
* </InfiniteScroll>
|
|
81
|
+
*
|
|
82
|
+
* // Scrollable container with a fixed height (internal scroll viewport)
|
|
83
|
+
* <InfiniteScroll
|
|
84
|
+
* scrollable
|
|
85
|
+
* className="max-h-96"
|
|
86
|
+
* hasMore={ hasMore }
|
|
87
|
+
* loading={ loading }
|
|
88
|
+
* onLoadMore={ loadNextPage }
|
|
89
|
+
* >
|
|
90
|
+
* { rows }
|
|
91
|
+
* </InfiniteScroll>
|
|
92
|
+
*
|
|
93
|
+
* // Reverse / chat mode (older messages load when scrolling up).
|
|
94
|
+
* // `flex flex-col-reverse` is applied automatically ; pass messages newest-first.
|
|
95
|
+
* <InfiniteScroll
|
|
96
|
+
* reverse
|
|
97
|
+
* scrollable
|
|
98
|
+
* className="max-h-96"
|
|
99
|
+
* hasMore={ hasMore }
|
|
100
|
+
* loading={ loading }
|
|
101
|
+
* onLoadMore={ loadOlderMessages }
|
|
102
|
+
* >
|
|
103
|
+
* { messagesNewestFirst.map( m => <ChatBubble key={ m.id } { ...m } /> ) }
|
|
104
|
+
* </InfiniteScroll>
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
const InfiniteScroll =
|
|
108
|
+
({
|
|
109
|
+
as ,
|
|
110
|
+
children ,
|
|
111
|
+
className ,
|
|
112
|
+
endMessage = null ,
|
|
113
|
+
hasMore = true ,
|
|
114
|
+
loader ,
|
|
115
|
+
loading = false ,
|
|
116
|
+
onLoadMore ,
|
|
117
|
+
reverse = false ,
|
|
118
|
+
rootMargin ,
|
|
119
|
+
scrollable = false ,
|
|
120
|
+
threshold ,
|
|
121
|
+
}) =>
|
|
122
|
+
{
|
|
123
|
+
const Component = as ?? DEFAULT_ELEMENT ;
|
|
124
|
+
|
|
125
|
+
/** @type {React.RefObject<HTMLElement>} - Container used as observer root when scrollable. */
|
|
126
|
+
const rootRef = useRef( null ) ;
|
|
127
|
+
|
|
128
|
+
const { ref : sentinelRef } = useInfiniteScroll
|
|
129
|
+
({
|
|
130
|
+
hasMore ,
|
|
131
|
+
loading ,
|
|
132
|
+
onLoadMore ,
|
|
133
|
+
root : scrollable ? rootRef : null ,
|
|
134
|
+
rootMargin ,
|
|
135
|
+
threshold ,
|
|
136
|
+
}) ;
|
|
137
|
+
|
|
138
|
+
// --------- Building blocks
|
|
139
|
+
|
|
140
|
+
const sentinel = hasMore
|
|
141
|
+
? <div ref={ sentinelRef } aria-hidden="true" className={ SENTINEL_CLASS } />
|
|
142
|
+
: null ;
|
|
143
|
+
|
|
144
|
+
const indicator = loading
|
|
145
|
+
? ( loader ?? <div className={ LOADER_WRAPPER_CLASS }><Loading /></div> )
|
|
146
|
+
: null ;
|
|
147
|
+
|
|
148
|
+
const footer = !hasMore ? endMessage : null ;
|
|
149
|
+
|
|
150
|
+
// --------- Render container
|
|
151
|
+
//
|
|
152
|
+
// DOM order is always [ children , indicator , sentinel , footer ].
|
|
153
|
+
// In `reverse` mode the container is laid out bottom-to-top (flex-col-reverse),
|
|
154
|
+
// so the sentinel / indicator / footer naturally end up at the visual top and
|
|
155
|
+
// the scroll stays anchored at the bottom.
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Component
|
|
159
|
+
className = { cn( scrollable && SCROLLABLE_CLASS , reverse && REVERSE_CLASS , className ) }
|
|
160
|
+
ref = { rootRef }
|
|
161
|
+
>
|
|
162
|
+
{ children }
|
|
163
|
+
{ indicator }
|
|
164
|
+
{ sentinel }
|
|
165
|
+
{ footer }
|
|
166
|
+
</Component>
|
|
167
|
+
) ;
|
|
168
|
+
} ;
|
|
169
|
+
|
|
170
|
+
InfiniteScroll.displayName = 'InfiniteScroll' ;
|
|
171
|
+
|
|
172
|
+
export default InfiniteScroll ;
|
|
@@ -19,73 +19,185 @@ import
|
|
|
19
19
|
}
|
|
20
20
|
from '../../themes/components/modal' ;
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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={
|
|
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={
|
|
356
|
+
<div className={ contentClasses }>
|
|
236
357
|
{ children }
|
|
237
358
|
</div>
|
|
238
359
|
|
|
239
|
-
{
|
|
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 ;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
3
|
+
import { useCallback , useEffect , useState } from 'react' ;
|
|
4
|
+
|
|
5
|
+
import InfiniteScroll from '@/components/layouts/InfiniteScroll' ;
|
|
6
|
+
import List from '@/components/lists/List' ;
|
|
7
|
+
import ListRow from '@/components/lists/ListRow' ;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Number of items appended on each load.
|
|
11
|
+
* @type {number}
|
|
12
|
+
*/
|
|
13
|
+
const PAGE_SIZE = 15 ;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Total number of items available in the simulated dataset.
|
|
17
|
+
* @type {number}
|
|
18
|
+
*/
|
|
19
|
+
const TOTAL_ITEMS = 75 ;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Simulated network latency, in milliseconds.
|
|
23
|
+
* @type {number}
|
|
24
|
+
*/
|
|
25
|
+
const LATENCY = 700 ;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Simulates a paginated API call returning a slice of items.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} page - Zero-based page index to fetch.
|
|
31
|
+
* @returns {Promise<{ items: Array<{ id: number, title: string, subtitle: string }>, hasMore: boolean }>}
|
|
32
|
+
*/
|
|
33
|
+
const fetchPage = ( page ) => new Promise( resolve =>
|
|
34
|
+
{
|
|
35
|
+
setTimeout( () =>
|
|
36
|
+
{
|
|
37
|
+
const start = page * PAGE_SIZE ;
|
|
38
|
+
const end = Math.min( start + PAGE_SIZE , TOTAL_ITEMS ) ;
|
|
39
|
+
|
|
40
|
+
const items = [] ;
|
|
41
|
+
|
|
42
|
+
for ( let i = start ; i < end ; i++ )
|
|
43
|
+
{
|
|
44
|
+
items.push
|
|
45
|
+
({
|
|
46
|
+
id : i + 1 ,
|
|
47
|
+
title : `Item #${ i + 1 }` ,
|
|
48
|
+
subtitle : `Loaded on page ${ page + 1 }` ,
|
|
49
|
+
}) ;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
resolve({ items , hasMore : end < TOTAL_ITEMS }) ;
|
|
53
|
+
}
|
|
54
|
+
, LATENCY ) ;
|
|
55
|
+
}) ;
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Reverse / chat demo
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Number of messages loaded on each "load older" request.
|
|
63
|
+
* @type {number}
|
|
64
|
+
*/
|
|
65
|
+
const CHAT_PAGE_SIZE = 8 ;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Side of the conversation a speaker sits on.
|
|
69
|
+
*/
|
|
70
|
+
const START = 'start' ;
|
|
71
|
+
const END = 'end' ;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Avatars used by the two speakers.
|
|
75
|
+
*/
|
|
76
|
+
const KENOBI_AVATAR = 'https://img.daisyui.com/images/profile/demo/kenobee@192.webp' ;
|
|
77
|
+
const ANAKIN_AVATAR = 'https://img.daisyui.com/images/profile/demo/anakeen@192.webp' ;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Bubble color (DaisyUI) used for the "current user" (Anakin).
|
|
81
|
+
* @type {string}
|
|
82
|
+
*/
|
|
83
|
+
const OWN_BUBBLE_COLOR = 'chat-bubble-primary' ;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The conversation, ordered oldest → newest. Each line alternates between
|
|
87
|
+
* Obi-Wan (chat-start) and Anakin (chat-end).
|
|
88
|
+
*/
|
|
89
|
+
const SCRIPT =
|
|
90
|
+
[
|
|
91
|
+
[ 'obiwan' , 'It was said that you would destroy the Sith, not join them.' ] ,
|
|
92
|
+
[ 'anakin' , "Don't lecture me, Obi-Wan." ] ,
|
|
93
|
+
[ 'obiwan' , 'It was you who would bring balance to the Force…' ] ,
|
|
94
|
+
[ 'anakin' , 'I should have known the Jedi were plotting to take over.' ] ,
|
|
95
|
+
[ 'obiwan' , '…not leave it in Darkness.' ] ,
|
|
96
|
+
[ 'anakin' , "If you're not with me, then you're my enemy." ] ,
|
|
97
|
+
[ 'obiwan' , 'Only a Sith deals in absolutes.' ] ,
|
|
98
|
+
[ 'anakin' , 'You will try.' ] ,
|
|
99
|
+
[ 'obiwan' , 'You were the Chosen One!' ] ,
|
|
100
|
+
[ 'anakin' , 'I hate you!' ] ,
|
|
101
|
+
[ 'obiwan' , 'You were my brother, Anakin.' ] ,
|
|
102
|
+
[ 'obiwan' , 'I loved you.' ] ,
|
|
103
|
+
[ 'anakin' , 'What kind of nonsense is this?' ] ,
|
|
104
|
+
[ 'anakin' , 'Put me on the Council and not make me a Master!?' ] ,
|
|
105
|
+
[ 'obiwan' , 'Anakin, calm down.' ] ,
|
|
106
|
+
[ 'anakin' , "That's never been done in the history of the Jedi." ] ,
|
|
107
|
+
[ 'anakin' , "It's insulting!" ] ,
|
|
108
|
+
[ 'obiwan' , 'You have been given a great honor.' ] ,
|
|
109
|
+
[ 'obiwan' , 'To be on the Council at your age…' ] ,
|
|
110
|
+
[ 'obiwan' , "It's never happened before." ] ,
|
|
111
|
+
[ 'anakin' , 'The Council wants me to spy on the Chancellor?' ] ,
|
|
112
|
+
[ 'obiwan' , 'The Council is asking you.' ] ,
|
|
113
|
+
[ 'anakin' , 'This is treason.' ] ,
|
|
114
|
+
[ 'obiwan' , 'Be mindful of your thoughts, Anakin.' ] ,
|
|
115
|
+
[ 'anakin' , 'I have brought peace, freedom, justice…' ] ,
|
|
116
|
+
[ 'obiwan' , 'Your new empire?!' ] ,
|
|
117
|
+
[ 'anakin' , "Don't make me kill you." ] ,
|
|
118
|
+
[ 'obiwan' , 'I will do what I must.' ] ,
|
|
119
|
+
] ;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Builds the full conversation as message objects, oldest → newest.
|
|
123
|
+
*
|
|
124
|
+
* @returns {Array<{ id: number, side: string, author: string, avatar: string, color: string, text: string, time: string }>}
|
|
125
|
+
*/
|
|
126
|
+
const buildConversation = () => SCRIPT.map( ( [ speaker , text ] , index ) =>
|
|
127
|
+
{
|
|
128
|
+
const isObiwan = speaker === 'obiwan' ;
|
|
129
|
+
|
|
130
|
+
const hours = 12 + Math.floor( index / 60 ) ;
|
|
131
|
+
const minutes = index % 60 ;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
id : index + 1 ,
|
|
135
|
+
side : isObiwan ? START : END ,
|
|
136
|
+
author : isObiwan ? 'Obi-Wan Kenobi' : 'Anakin' ,
|
|
137
|
+
avatar : isObiwan ? KENOBI_AVATAR : ANAKIN_AVATAR ,
|
|
138
|
+
color : isObiwan ? '' : OWN_BUBBLE_COLOR ,
|
|
139
|
+
text ,
|
|
140
|
+
time : `${ hours }:${ String( minutes ).padStart( 2 , '0' ) }` ,
|
|
141
|
+
} ;
|
|
142
|
+
} ) ;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Full conversation, computed once.
|
|
146
|
+
*/
|
|
147
|
+
const CONVERSATION = buildConversation() ;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A single chat message, rendered with DaisyUI chat bubble classes.
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} props
|
|
153
|
+
* @param {string} props.author - Author name shown in the header.
|
|
154
|
+
* @param {string} props.avatar - Author image URL.
|
|
155
|
+
* @param {string} props.color - DaisyUI chat-bubble color class.
|
|
156
|
+
* @param {string} props.side - 'start' or 'end'.
|
|
157
|
+
* @param {string} props.text - Message body.
|
|
158
|
+
* @param {string} props.time - Timestamp shown in the header.
|
|
159
|
+
*/
|
|
160
|
+
const ChatMessage =
|
|
161
|
+
({
|
|
162
|
+
author ,
|
|
163
|
+
avatar ,
|
|
164
|
+
color ,
|
|
165
|
+
side ,
|
|
166
|
+
text ,
|
|
167
|
+
time ,
|
|
168
|
+
}) =>
|
|
169
|
+
(
|
|
170
|
+
<div className={ `chat ${ side === END ? 'chat-end' : 'chat-start' }` }>
|
|
171
|
+
<div className="chat-image avatar">
|
|
172
|
+
<div className="w-10 rounded-full">
|
|
173
|
+
<img alt={ author } src={ avatar } />
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<div className="chat-header text-xs">
|
|
177
|
+
{ author }
|
|
178
|
+
<time className="opacity-50 ml-1">{ time }</time>
|
|
179
|
+
</div>
|
|
180
|
+
<div className={ `chat-bubble ${ color }` }>{ text }</div>
|
|
181
|
+
</div>
|
|
182
|
+
) ;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Chat panel : a reverse infinite scroll that loads older messages when the
|
|
186
|
+
* user scrolls up. Messages are kept oldest → newest in state and rendered
|
|
187
|
+
* newest-first, as required by `<InfiniteScroll reverse>`.
|
|
188
|
+
*/
|
|
189
|
+
const ChatPanel = () =>
|
|
190
|
+
{
|
|
191
|
+
const [ loaded , setLoaded ] = useState( [] ) ;
|
|
192
|
+
const [ loading , setLoading ] = useState( false ) ;
|
|
193
|
+
const [ hasMore , setHasMore ] = useState( true ) ;
|
|
194
|
+
|
|
195
|
+
// --------- Prepend the previous (older) page of messages
|
|
196
|
+
|
|
197
|
+
const loadOlder = useCallback( async () =>
|
|
198
|
+
{
|
|
199
|
+
if ( loading || !hasMore )
|
|
200
|
+
{
|
|
201
|
+
return ;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setLoading( true ) ;
|
|
205
|
+
|
|
206
|
+
await new Promise( resolve => setTimeout( resolve , LATENCY ) ) ;
|
|
207
|
+
|
|
208
|
+
const remaining = CONVERSATION.length - loaded.length ;
|
|
209
|
+
const start = Math.max( 0 , remaining - CHAT_PAGE_SIZE ) ;
|
|
210
|
+
const older = CONVERSATION.slice( start , remaining ) ;
|
|
211
|
+
|
|
212
|
+
setLoaded( prev => [ ...older , ...prev ] ) ;
|
|
213
|
+
setHasMore( start > 0 ) ;
|
|
214
|
+
setLoading( false ) ;
|
|
215
|
+
}
|
|
216
|
+
, [ hasMore , loaded.length , loading ] ) ;
|
|
217
|
+
|
|
218
|
+
// --------- Initial load (most recent page)
|
|
219
|
+
|
|
220
|
+
useEffect( () =>
|
|
221
|
+
{
|
|
222
|
+
loadOlder() ;
|
|
223
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
224
|
+
} , [] ) ;
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<InfiniteScroll
|
|
228
|
+
className = "max-h-96 p-4 gap-1 rounded-box border border-base-300 bg-base-100"
|
|
229
|
+
endMessage = {
|
|
230
|
+
<p className="text-center text-xs opacity-60 py-2">
|
|
231
|
+
— Beginning of the conversation —
|
|
232
|
+
</p>
|
|
233
|
+
}
|
|
234
|
+
hasMore = { hasMore }
|
|
235
|
+
loading = { loading }
|
|
236
|
+
onLoadMore = { loadOlder }
|
|
237
|
+
reverse
|
|
238
|
+
scrollable
|
|
239
|
+
>
|
|
240
|
+
{ [ ...loaded ].reverse().map( message => (
|
|
241
|
+
<ChatMessage key={ message.id } { ...message } />
|
|
242
|
+
) ) }
|
|
243
|
+
</InfiniteScroll>
|
|
244
|
+
) ;
|
|
245
|
+
} ;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* InfiniteScroll showcase : a forward scrollable list and a reverse chat panel,
|
|
249
|
+
* both lazily loading paginated content.
|
|
250
|
+
*/
|
|
251
|
+
const InfiniteScrollDemo = () =>
|
|
252
|
+
{
|
|
253
|
+
const [ items , setItems ] = useState( [] ) ;
|
|
254
|
+
const [ page , setPage ] = useState( 0 ) ;
|
|
255
|
+
const [ loading , setLoading ] = useState( false ) ;
|
|
256
|
+
const [ hasMore , setHasMore ] = useState( true ) ;
|
|
257
|
+
|
|
258
|
+
// --------- Append the next page, guarding against concurrent loads
|
|
259
|
+
|
|
260
|
+
const loadMore = useCallback( async () =>
|
|
261
|
+
{
|
|
262
|
+
if ( loading || !hasMore )
|
|
263
|
+
{
|
|
264
|
+
return ;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
setLoading( true ) ;
|
|
268
|
+
|
|
269
|
+
const { items : newItems , hasMore : more } = await fetchPage( page ) ;
|
|
270
|
+
|
|
271
|
+
setItems( prev => [ ...prev , ...newItems ] ) ;
|
|
272
|
+
setHasMore( more ) ;
|
|
273
|
+
setPage( prev => prev + 1 ) ;
|
|
274
|
+
setLoading( false ) ;
|
|
275
|
+
}
|
|
276
|
+
, [ hasMore , loading , page ] ) ;
|
|
277
|
+
|
|
278
|
+
// --------- Initial load
|
|
279
|
+
|
|
280
|
+
useEffect( () =>
|
|
281
|
+
{
|
|
282
|
+
loadMore() ;
|
|
283
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
284
|
+
} , [] ) ;
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div className="flex flex-col gap-8">
|
|
288
|
+
|
|
289
|
+
{/* Forward scroll */}
|
|
290
|
+
<div className="card bg-base-200 shadow-xl">
|
|
291
|
+
<div className="card-body">
|
|
292
|
+
|
|
293
|
+
<h2 className="card-title text-2xl mb-2">Scrollable container</h2>
|
|
294
|
+
<p className="text-sm opacity-70 mb-4">
|
|
295
|
+
{ items.length } / { TOTAL_ITEMS } items loaded — scroll down to load more.
|
|
296
|
+
</p>
|
|
297
|
+
|
|
298
|
+
<InfiniteScroll
|
|
299
|
+
className = "max-h-96 py-2 rounded-box border border-base-300 bg-base-100"
|
|
300
|
+
endMessage = {
|
|
301
|
+
<p className="text-center text-sm opacity-60 py-4">
|
|
302
|
+
— End of the list —
|
|
303
|
+
</p>
|
|
304
|
+
}
|
|
305
|
+
hasMore = { hasMore }
|
|
306
|
+
loading = { loading }
|
|
307
|
+
onLoadMore = { loadMore }
|
|
308
|
+
scrollable
|
|
309
|
+
>
|
|
310
|
+
<List>
|
|
311
|
+
{ items.map( item => (
|
|
312
|
+
<ListRow
|
|
313
|
+
key = { item.id }
|
|
314
|
+
title = { item.title }
|
|
315
|
+
subtitle = { item.subtitle }
|
|
316
|
+
/>
|
|
317
|
+
) ) }
|
|
318
|
+
</List>
|
|
319
|
+
</InfiniteScroll>
|
|
320
|
+
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* Reverse / chat */}
|
|
325
|
+
<div className="card bg-base-200 shadow-xl">
|
|
326
|
+
<div className="card-body">
|
|
327
|
+
|
|
328
|
+
<h2 className="card-title text-2xl mb-2">Reverse mode — chat</h2>
|
|
329
|
+
<p className="text-sm opacity-70 mb-4">
|
|
330
|
+
Scroll up to load older messages. New messages stay pinned at the
|
|
331
|
+
bottom and the scroll position never jumps.
|
|
332
|
+
</p>
|
|
333
|
+
|
|
334
|
+
<ChatPanel />
|
|
335
|
+
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
</div>
|
|
340
|
+
) ;
|
|
341
|
+
} ;
|
|
342
|
+
|
|
343
|
+
export default InfiniteScrollDemo ;
|
|
@@ -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><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}`}></code></pre>
|
|
692
|
+
<pre data-prefix="5"><code> <div className="flex-1 min-h-0 overflow-y-auto ..."></code></pre>
|
|
693
|
+
<pre data-prefix="6"><code> {`{form fields}`}</code></pre>
|
|
694
|
+
<pre data-prefix="7"><code> </div></code></pre>
|
|
695
|
+
<pre data-prefix="8"><code> <div className="shrink-0 flex border-t bg-base-100 ..."></code></pre>
|
|
696
|
+
<pre data-prefix="9"><code> {`{status + cancel + save}`}</code></pre>
|
|
697
|
+
<pre data-prefix="10"><code> </div></code></pre>
|
|
698
|
+
<pre data-prefix="11"><code></Modal></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><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>></code></pre>
|
|
709
|
+
<pre data-prefix="5"><code> <form className="flex flex-col gap-4"></code></pre>
|
|
710
|
+
<pre data-prefix="6"><code> {`{form fields}`}</code></pre>
|
|
711
|
+
<pre data-prefix="7"><code> </form></code></pre>
|
|
712
|
+
<pre data-prefix="8"><code></Modal></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">
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a DOM element from either a React ref object or a raw element.
|
|
3
|
+
*
|
|
4
|
+
* Useful when an API (IntersectionObserver, ResizeObserver, …) expects a DOM
|
|
5
|
+
* element but the caller may provide either a ref or the element directly.
|
|
6
|
+
*
|
|
7
|
+
* @param {React.RefObject<Element>|Element|null} [target] - Ref object, element or null.
|
|
8
|
+
* @returns {Element|null} The resolved DOM element, or null.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```js
|
|
12
|
+
* resolveRefElement( myRef ) ; // → myRef.current
|
|
13
|
+
* resolveRefElement( domNode ) ; // → domNode
|
|
14
|
+
* resolveRefElement( null ) ; // → null
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
const resolveRefElement = ( target ) =>
|
|
18
|
+
{
|
|
19
|
+
if ( target && typeof target === 'object' && 'current' in target )
|
|
20
|
+
{
|
|
21
|
+
return target.current ;
|
|
22
|
+
}
|
|
23
|
+
return target ?? null ;
|
|
24
|
+
} ;
|
|
25
|
+
|
|
26
|
+
export default resolveRefElement ;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
3
|
+
import { useEffect , useRef } from 'react' ;
|
|
4
|
+
|
|
5
|
+
import resolveRefElement from '@/helpers/react/resolveRefElement' ;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default distance, before the sentinel is reached, at which loading is triggered.
|
|
9
|
+
* @type {string}
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_ROOT_MARGIN = '200px' ;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default visibility threshold passed to the IntersectionObserver.
|
|
15
|
+
* @type {number}
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_THRESHOLD = 0 ;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook that invokes a callback when a sentinel element scrolls into view.
|
|
21
|
+
*
|
|
22
|
+
* Building block for infinite scrolling / "load more on scroll". Attach the
|
|
23
|
+
* returned `ref` to a sentinel element placed at the edge of your list ; when
|
|
24
|
+
* that sentinel enters the viewport (or the scrollable `root`), `onLoadMore`
|
|
25
|
+
* is called. Observation is paused while `loading` is true and stops once
|
|
26
|
+
* `hasMore` becomes false.
|
|
27
|
+
*
|
|
28
|
+
* Note : `onLoadMore` should be stable (wrap it in `useCallback`), otherwise
|
|
29
|
+
* the observer is recreated on every render.
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} [options]
|
|
32
|
+
* @param {boolean} [options.hasMore=true] - Whether more items can be loaded.
|
|
33
|
+
* @param {boolean} [options.loading=false] - Pauses observation while a load is in flight.
|
|
34
|
+
* @param {Function} [options.onLoadMore] - Called when the sentinel becomes visible.
|
|
35
|
+
* @param {React.RefObject<Element>|Element|null} [options.root=null] - Scrollable container used as observer root ; defaults to the viewport.
|
|
36
|
+
* @param {string} [options.rootMargin=DEFAULT_ROOT_MARGIN]- Pre-load distance before the sentinel is reached.
|
|
37
|
+
* @param {number} [options.threshold=DEFAULT_THRESHOLD] - Visibility threshold (0-1).
|
|
38
|
+
*
|
|
39
|
+
* @returns {{ ref: React.RefObject<HTMLElement> }} Ref to attach to the sentinel element.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```js
|
|
43
|
+
* const onLoadMore = useCallback( () => loadNextPage() , [ loadNextPage ] ) ;
|
|
44
|
+
* const { ref } = useInfiniteScroll({ hasMore , loading , onLoadMore }) ;
|
|
45
|
+
*
|
|
46
|
+
* <ul>
|
|
47
|
+
* { items.map( item => <li key={ item.id }>{ item.label }</li> ) }
|
|
48
|
+
* <li ref={ ref } aria-hidden="true" />
|
|
49
|
+
* </ul>
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
const useInfiniteScroll =
|
|
53
|
+
({
|
|
54
|
+
hasMore = true ,
|
|
55
|
+
loading = false ,
|
|
56
|
+
onLoadMore ,
|
|
57
|
+
root = null ,
|
|
58
|
+
rootMargin = DEFAULT_ROOT_MARGIN ,
|
|
59
|
+
threshold = DEFAULT_THRESHOLD ,
|
|
60
|
+
}
|
|
61
|
+
= {}) =>
|
|
62
|
+
{
|
|
63
|
+
/** @type {React.RefObject<HTMLElement>} */
|
|
64
|
+
const ref = useRef( null ) ;
|
|
65
|
+
|
|
66
|
+
useEffect( () =>
|
|
67
|
+
{
|
|
68
|
+
const element = ref.current ;
|
|
69
|
+
|
|
70
|
+
if ( !element || !hasMore || loading )
|
|
71
|
+
{
|
|
72
|
+
return ;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const observer = new IntersectionObserver
|
|
76
|
+
(
|
|
77
|
+
( [ entry ] ) =>
|
|
78
|
+
{
|
|
79
|
+
if ( entry.isIntersecting )
|
|
80
|
+
{
|
|
81
|
+
onLoadMore?.() ;
|
|
82
|
+
}
|
|
83
|
+
} ,
|
|
84
|
+
{
|
|
85
|
+
root : resolveRefElement( root ) ,
|
|
86
|
+
rootMargin ,
|
|
87
|
+
threshold ,
|
|
88
|
+
}
|
|
89
|
+
) ;
|
|
90
|
+
|
|
91
|
+
observer.observe( element ) ;
|
|
92
|
+
|
|
93
|
+
return () => observer.disconnect() ;
|
|
94
|
+
}
|
|
95
|
+
, [ hasMore , loading , onLoadMore , root , rootMargin , threshold ] ) ;
|
|
96
|
+
|
|
97
|
+
return { ref } ;
|
|
98
|
+
} ;
|
|
99
|
+
|
|
100
|
+
export default useInfiniteScroll ;
|
|
@@ -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