mvc-kit 2.12.0 → 2.12.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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
Column,
|
|
4
|
+
SortHeaderProps,
|
|
5
|
+
SelectionState,
|
|
6
|
+
SelectionHelper,
|
|
7
|
+
PaginationState,
|
|
8
|
+
PaginationHelper,
|
|
9
|
+
PaginationInfo,
|
|
10
|
+
SortingHelper,
|
|
11
|
+
AsyncStateProps,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { isSelectionHelper, isPaginationHelper, isSortingHelper } from './types';
|
|
14
|
+
import type { SortDescriptor } from '../../Sorting';
|
|
15
|
+
|
|
16
|
+
// ── Prop resolution helpers ──
|
|
17
|
+
|
|
18
|
+
interface ResolvedSelection {
|
|
19
|
+
selected: ReadonlySet<any>;
|
|
20
|
+
onToggle: (key: any) => void;
|
|
21
|
+
onToggleAll: (allKeys: any[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveSelectionProp(selection: SelectionState | SelectionHelper): ResolvedSelection {
|
|
25
|
+
if (isSelectionHelper(selection)) {
|
|
26
|
+
return {
|
|
27
|
+
selected: selection.selected,
|
|
28
|
+
onToggle: (key) => selection.toggle(key),
|
|
29
|
+
onToggleAll: (allKeys) => selection.toggleAll(allKeys),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
selected: selection.selected,
|
|
34
|
+
onToggle: selection.onToggle,
|
|
35
|
+
onToggleAll: (allKeys) => selection.onToggleAll(allKeys),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveSortProp(
|
|
40
|
+
sort: readonly SortDescriptor[] | SortingHelper,
|
|
41
|
+
onSort?: (key: string) => void,
|
|
42
|
+
): { sorts: readonly SortDescriptor[]; onSort: ((key: string) => void) | undefined } {
|
|
43
|
+
if (isSortingHelper(sort)) {
|
|
44
|
+
return { sorts: sort.sorts, onSort: (key) => sort.toggle(key) };
|
|
45
|
+
}
|
|
46
|
+
return { sorts: sort, onSort };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolvePaginationProp(
|
|
50
|
+
pagination: PaginationState | PaginationHelper,
|
|
51
|
+
pageSize?: number,
|
|
52
|
+
paginationTotal?: number,
|
|
53
|
+
): PaginationInfo {
|
|
54
|
+
if (isPaginationHelper(pagination)) {
|
|
55
|
+
const total = paginationTotal ?? 0;
|
|
56
|
+
const ps = pagination.pageSize;
|
|
57
|
+
const pageCount = Math.max(1, Math.ceil(total / ps));
|
|
58
|
+
const page = pagination.page;
|
|
59
|
+
return {
|
|
60
|
+
page,
|
|
61
|
+
pageCount,
|
|
62
|
+
total,
|
|
63
|
+
pageSize: ps,
|
|
64
|
+
hasPrev: page > 1,
|
|
65
|
+
hasNext: page < pageCount,
|
|
66
|
+
goToPage: (p) => pagination.setPage(p),
|
|
67
|
+
goPrev: () => pagination.setPage(page - 1),
|
|
68
|
+
goNext: () => pagination.setPage(page + 1),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const total = pagination.total;
|
|
72
|
+
const ps = pageSize ?? 10;
|
|
73
|
+
const pageCount = Math.max(1, Math.ceil(total / ps));
|
|
74
|
+
const page = pagination.page;
|
|
75
|
+
return {
|
|
76
|
+
page,
|
|
77
|
+
pageCount,
|
|
78
|
+
total,
|
|
79
|
+
pageSize: ps,
|
|
80
|
+
hasPrev: page > 1,
|
|
81
|
+
hasNext: page < pageCount,
|
|
82
|
+
goToPage: pagination.onPageChange,
|
|
83
|
+
goPrev: () => pagination.onPageChange(page - 1),
|
|
84
|
+
goNext: () => pagination.onPageChange(page + 1),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Props for the DataTable headless component. */
|
|
89
|
+
export interface DataTableProps<T> extends AsyncStateProps {
|
|
90
|
+
items: T[];
|
|
91
|
+
columns: Column<T>[];
|
|
92
|
+
keyOf?: (item: T) => string | number;
|
|
93
|
+
pageSize?: number;
|
|
94
|
+
|
|
95
|
+
// Controlled state — accepts object-literal OR helper instance
|
|
96
|
+
sort?: readonly SortDescriptor[] | SortingHelper;
|
|
97
|
+
onSort?: (key: string) => void;
|
|
98
|
+
selection?: SelectionState | SelectionHelper;
|
|
99
|
+
pagination?: PaginationState | PaginationHelper;
|
|
100
|
+
paginationTotal?: number;
|
|
101
|
+
|
|
102
|
+
// Render slots
|
|
103
|
+
renderEmpty?: () => ReactNode;
|
|
104
|
+
renderLoading?: () => ReactNode;
|
|
105
|
+
renderError?: (error: string) => ReactNode;
|
|
106
|
+
renderSortIndicator?: (props: SortHeaderProps) => ReactNode;
|
|
107
|
+
renderRow?: (item: T, index: number, defaultCells: ReactNode) => ReactNode;
|
|
108
|
+
renderPagination?: (info: PaginationInfo) => ReactNode;
|
|
109
|
+
|
|
110
|
+
className?: string;
|
|
111
|
+
'aria-label'?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const defaultKeyOf = (item: any) => item.id;
|
|
115
|
+
|
|
116
|
+
function getAriaSortValue(key: string, sorts: readonly SortDescriptor[] | undefined): 'ascending' | 'descending' | 'none' {
|
|
117
|
+
if (!sorts) return 'none';
|
|
118
|
+
const desc = sorts.find(s => s.key === key);
|
|
119
|
+
if (!desc) return 'none';
|
|
120
|
+
return desc.direction === 'asc' ? 'ascending' : 'descending';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Headless data table with sort headers, selection checkboxes, and pagination slots.
|
|
125
|
+
* Renders semantic HTML (`<table>`) with data attributes for styling.
|
|
126
|
+
* Accepts Sorting/Selection/Pagination helpers directly via duck-typing.
|
|
127
|
+
*/
|
|
128
|
+
export function DataTable<T>({
|
|
129
|
+
items,
|
|
130
|
+
columns,
|
|
131
|
+
keyOf = defaultKeyOf,
|
|
132
|
+
pageSize,
|
|
133
|
+
sort,
|
|
134
|
+
onSort,
|
|
135
|
+
selection,
|
|
136
|
+
loading,
|
|
137
|
+
error,
|
|
138
|
+
pagination,
|
|
139
|
+
paginationTotal,
|
|
140
|
+
renderEmpty,
|
|
141
|
+
renderLoading,
|
|
142
|
+
renderError,
|
|
143
|
+
renderSortIndicator,
|
|
144
|
+
renderRow,
|
|
145
|
+
renderPagination,
|
|
146
|
+
className,
|
|
147
|
+
'aria-label': ariaLabel,
|
|
148
|
+
}: DataTableProps<T>) {
|
|
149
|
+
if (loading && renderLoading) return <>{renderLoading()}</>;
|
|
150
|
+
if (error && renderError) return <>{renderError(error)}</>;
|
|
151
|
+
if (items.length === 0 && renderEmpty) return <>{renderEmpty()}</>;
|
|
152
|
+
|
|
153
|
+
// ── Resolve props ──
|
|
154
|
+
const resolvedSelection = selection ? resolveSelectionProp(selection) : undefined;
|
|
155
|
+
|
|
156
|
+
let resolvedSorts: readonly SortDescriptor[] | undefined;
|
|
157
|
+
let resolvedOnSort: ((key: string) => void) | undefined;
|
|
158
|
+
if (sort) {
|
|
159
|
+
const resolved = resolveSortProp(sort, onSort);
|
|
160
|
+
resolvedSorts = resolved.sorts;
|
|
161
|
+
resolvedOnSort = resolved.onSort;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let displayItems = items;
|
|
165
|
+
let paginationInfo: PaginationInfo | undefined;
|
|
166
|
+
if (pagination) {
|
|
167
|
+
paginationInfo = resolvePaginationProp(pagination, pageSize, paginationTotal);
|
|
168
|
+
} else if (pageSize && !pagination) {
|
|
169
|
+
displayItems = items.slice(0, pageSize);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Selection: check indeterminate state
|
|
173
|
+
const allKeys = resolvedSelection ? displayItems.map(item => keyOf(item)) : [];
|
|
174
|
+
const allSelected = resolvedSelection && allKeys.length > 0 && allKeys.every(k => resolvedSelection!.selected.has(k));
|
|
175
|
+
const someSelected = resolvedSelection && allKeys.some(k => resolvedSelection!.selected.has(k));
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div data-component="data-table" className={className}>
|
|
179
|
+
<table role="grid" aria-label={ariaLabel}>
|
|
180
|
+
<thead>
|
|
181
|
+
<tr>
|
|
182
|
+
{resolvedSelection && (
|
|
183
|
+
<th data-column="select">
|
|
184
|
+
<input
|
|
185
|
+
type="checkbox"
|
|
186
|
+
checked={!!allSelected}
|
|
187
|
+
ref={(el) => {
|
|
188
|
+
if (el) el.indeterminate = !!someSelected && !allSelected;
|
|
189
|
+
}}
|
|
190
|
+
onChange={() => resolvedSelection!.onToggleAll(allKeys)}
|
|
191
|
+
aria-label="Select all"
|
|
192
|
+
/>
|
|
193
|
+
</th>
|
|
194
|
+
)}
|
|
195
|
+
{columns.map((col) => {
|
|
196
|
+
const isSortable = col.sortable && resolvedOnSort;
|
|
197
|
+
const sortDesc = resolvedSorts?.find(s => s.key === col.key);
|
|
198
|
+
const isActive = !!sortDesc;
|
|
199
|
+
const sortIndex = resolvedSorts ? resolvedSorts.findIndex(s => s.key === col.key) : -1;
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<th
|
|
203
|
+
key={col.key}
|
|
204
|
+
data-sortable={isSortable ? '' : undefined}
|
|
205
|
+
data-sorted={isActive ? '' : undefined}
|
|
206
|
+
data-align={col.align}
|
|
207
|
+
style={col.width ? { width: col.width } : undefined}
|
|
208
|
+
aria-sort={isSortable ? getAriaSortValue(col.key, resolvedSorts) : undefined}
|
|
209
|
+
>
|
|
210
|
+
{isSortable ? (
|
|
211
|
+
<button type="button" onClick={() => resolvedOnSort!(col.key)}>
|
|
212
|
+
{col.header}
|
|
213
|
+
{renderSortIndicator?.({
|
|
214
|
+
active: isActive,
|
|
215
|
+
direction: sortDesc?.direction ?? 'asc',
|
|
216
|
+
index: sortIndex,
|
|
217
|
+
onToggle: () => resolvedOnSort!(col.key),
|
|
218
|
+
})}
|
|
219
|
+
</button>
|
|
220
|
+
) : (
|
|
221
|
+
col.header
|
|
222
|
+
)}
|
|
223
|
+
</th>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</tr>
|
|
227
|
+
</thead>
|
|
228
|
+
<tbody>
|
|
229
|
+
{displayItems.map((item, index) => {
|
|
230
|
+
const key = keyOf(item);
|
|
231
|
+
const isSelected = resolvedSelection?.selected.has(key);
|
|
232
|
+
|
|
233
|
+
const cells = (
|
|
234
|
+
<>
|
|
235
|
+
{resolvedSelection && (
|
|
236
|
+
<td data-column="select">
|
|
237
|
+
<input
|
|
238
|
+
type="checkbox"
|
|
239
|
+
checked={!!isSelected}
|
|
240
|
+
onChange={() => resolvedSelection!.onToggle(key)}
|
|
241
|
+
aria-label={`Select row ${key}`}
|
|
242
|
+
/>
|
|
243
|
+
</td>
|
|
244
|
+
)}
|
|
245
|
+
{columns.map((col) => (
|
|
246
|
+
<td
|
|
247
|
+
key={col.key}
|
|
248
|
+
data-align={col.align}
|
|
249
|
+
>
|
|
250
|
+
{col.render(item, index)}
|
|
251
|
+
</td>
|
|
252
|
+
))}
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<tr key={key} data-selected={isSelected ? '' : undefined}>
|
|
258
|
+
{renderRow ? renderRow(item, index, cells) : cells}
|
|
259
|
+
</tr>
|
|
260
|
+
);
|
|
261
|
+
})}
|
|
262
|
+
</tbody>
|
|
263
|
+
</table>
|
|
264
|
+
{paginationInfo && renderPagination?.(paginationInfo)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# InfiniteScroll
|
|
2
|
+
|
|
3
|
+
IntersectionObserver wrapper for infinite loading patterns. Renders a sentinel element that triggers `onLoadMore` when it enters the viewport.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Use InfiniteScroll to wrap a list (typically `CardList`) and automatically trigger page loads as the user scrolls. Pairs with the `Feed` helper for cursor tracking.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { InfiniteScroll, CardList } from 'mvc-kit/react';
|
|
17
|
+
|
|
18
|
+
function PostFeed() {
|
|
19
|
+
const [, vm] = useLocal(FeedVM, {});
|
|
20
|
+
return (
|
|
21
|
+
<InfiniteScroll
|
|
22
|
+
hasMore={vm.hasMore}
|
|
23
|
+
loading={vm.async.loadMore?.loading}
|
|
24
|
+
onLoadMore={() => vm.loadMore()}
|
|
25
|
+
renderEnd={() => <p>No more posts</p>}
|
|
26
|
+
>
|
|
27
|
+
<CardList items={vm.items} renderItem={post => <PostCard post={post} />} />
|
|
28
|
+
</InfiniteScroll>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Default | Description |
|
|
38
|
+
|------|------|---------|-------------|
|
|
39
|
+
| `hasMore` | `boolean` | *required* | Whether more data is available |
|
|
40
|
+
| `onLoadMore` | `() => void` | *required* | Called when sentinel enters viewport |
|
|
41
|
+
| `loading` | `boolean` | `false` | Suppresses sentinel while loading |
|
|
42
|
+
| `threshold` | `number` | `0.1` | IntersectionObserver threshold |
|
|
43
|
+
| `rootMargin` | `string` | `'0px'` | IntersectionObserver root margin |
|
|
44
|
+
| `direction` | `'down' \| 'up'` | `'down'` | Scroll direction; `'up'` applies `column-reverse` for chat UIs |
|
|
45
|
+
| `children` | `ReactNode` | *required* | Content to wrap |
|
|
46
|
+
| `className` | `string` | — | Container class |
|
|
47
|
+
|
|
48
|
+
### Render Slots
|
|
49
|
+
|
|
50
|
+
| Prop | Type | Description |
|
|
51
|
+
|------|------|-------------|
|
|
52
|
+
| `renderLoading` | `() => ReactNode` | Shown while loading |
|
|
53
|
+
| `renderEnd` | `() => ReactNode` | Shown when no more data |
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## How It Works
|
|
58
|
+
|
|
59
|
+
1. When `hasMore` is `true` and `loading` is `false`, a hidden sentinel `<div>` is appended after `children`
|
|
60
|
+
2. An IntersectionObserver watches the sentinel
|
|
61
|
+
3. When the sentinel enters the viewport, `onLoadMore` is called
|
|
62
|
+
4. While loading, the sentinel is removed to prevent duplicate calls
|
|
63
|
+
5. When `hasMore` becomes `false`, the sentinel is removed and `renderEnd` is shown
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## SSR Safety
|
|
68
|
+
|
|
69
|
+
The component guards against missing `IntersectionObserver` (server-side rendering). No observer is created if `typeof IntersectionObserver === 'undefined'`.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Data Attributes
|
|
74
|
+
|
|
75
|
+
| Attribute | Element | Description |
|
|
76
|
+
|-----------|---------|-------------|
|
|
77
|
+
| `data-component="infinite-scroll"` | Container `<div>` | Component identifier |
|
|
78
|
+
| `data-sentinel` | Sentinel `<div>` | The observed element (hidden, `aria-hidden="true"`) |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Customizing the Trigger Point
|
|
83
|
+
|
|
84
|
+
Use `rootMargin` to trigger loading before the user reaches the bottom:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
<InfiniteScroll
|
|
88
|
+
hasMore={vm.hasMore}
|
|
89
|
+
onLoadMore={() => vm.loadMore()}
|
|
90
|
+
rootMargin="0px 0px 200px 0px" // Trigger 200px before visible
|
|
91
|
+
>
|
|
92
|
+
{/* ... */}
|
|
93
|
+
</InfiniteScroll>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Reverse Scroll (Chat UI)
|
|
99
|
+
|
|
100
|
+
Use `direction="up"` for chat-style interfaces where new content loads at the top:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<InfiniteScroll
|
|
104
|
+
hasMore={vm.feed.hasMore}
|
|
105
|
+
loading={vm.async.loadOlder.loading}
|
|
106
|
+
onLoadMore={() => vm.loadOlder()}
|
|
107
|
+
direction="up"
|
|
108
|
+
renderEnd={() => <p>Beginning of conversation</p>}
|
|
109
|
+
>
|
|
110
|
+
{vm.messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
|
|
111
|
+
</InfiniteScroll>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
When `direction="up"`, the component applies `display: flex; flex-direction: column-reverse` so the sentinel appears at the visual top, triggering loads as the user scrolls up.
|
|
115
|
+
|
|
116
|
+
**Note:** `column-reverse` inverts scrollbar direction and may cause layout quirks in some browsers. For production chat UIs with complex scroll behavior, consider wrapping the container with scroll-anchoring or manual scroll position management.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, act } from '@testing-library/react';
|
|
5
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { InfiniteScroll } from './InfiniteScroll';
|
|
7
|
+
|
|
8
|
+
// Mock IntersectionObserver
|
|
9
|
+
let observerCallback: IntersectionObserverCallback;
|
|
10
|
+
let mockObserve: ReturnType<typeof vi.fn>;
|
|
11
|
+
let mockDisconnect: ReturnType<typeof vi.fn>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockObserve = vi.fn();
|
|
15
|
+
mockDisconnect = vi.fn();
|
|
16
|
+
|
|
17
|
+
global.IntersectionObserver = class MockIntersectionObserver {
|
|
18
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
19
|
+
observerCallback = callback;
|
|
20
|
+
}
|
|
21
|
+
observe = mockObserve;
|
|
22
|
+
unobserve = vi.fn();
|
|
23
|
+
disconnect = mockDisconnect;
|
|
24
|
+
root = null;
|
|
25
|
+
rootMargin = '';
|
|
26
|
+
thresholds = [] as number[];
|
|
27
|
+
takeRecords = () => [] as IntersectionObserverEntry[];
|
|
28
|
+
} as any;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('InfiniteScroll', () => {
|
|
36
|
+
it('renders children', () => {
|
|
37
|
+
render(
|
|
38
|
+
<InfiniteScroll hasMore={true} onLoadMore={() => {}}>
|
|
39
|
+
<p>Content</p>
|
|
40
|
+
</InfiniteScroll>,
|
|
41
|
+
);
|
|
42
|
+
expect(screen.getByText('Content')).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders sentinel when hasMore and not loading', () => {
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<InfiniteScroll hasMore={true} onLoadMore={() => {}}>
|
|
48
|
+
<p>Content</p>
|
|
49
|
+
</InfiniteScroll>,
|
|
50
|
+
);
|
|
51
|
+
const sentinel = container.querySelector('[data-sentinel]');
|
|
52
|
+
expect(sentinel).not.toBeNull();
|
|
53
|
+
expect(sentinel!.getAttribute('aria-hidden')).toBe('true');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not render sentinel when loading', () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<InfiniteScroll hasMore={true} loading={true} onLoadMore={() => {}}>
|
|
59
|
+
<p>Content</p>
|
|
60
|
+
</InfiniteScroll>,
|
|
61
|
+
);
|
|
62
|
+
expect(container.querySelector('[data-sentinel]')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not render sentinel when hasMore is false', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<InfiniteScroll hasMore={false} onLoadMore={() => {}}>
|
|
68
|
+
<p>Content</p>
|
|
69
|
+
</InfiniteScroll>,
|
|
70
|
+
);
|
|
71
|
+
expect(container.querySelector('[data-sentinel]')).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('calls onLoadMore when sentinel intersects', () => {
|
|
75
|
+
const onLoadMore = vi.fn();
|
|
76
|
+
render(
|
|
77
|
+
<InfiniteScroll hasMore={true} onLoadMore={onLoadMore}>
|
|
78
|
+
<p>Content</p>
|
|
79
|
+
</InfiniteScroll>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(mockObserve).toHaveBeenCalled();
|
|
83
|
+
|
|
84
|
+
// Simulate intersection
|
|
85
|
+
act(() => {
|
|
86
|
+
observerCallback(
|
|
87
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
88
|
+
{} as IntersectionObserver,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not call onLoadMore when not intersecting', () => {
|
|
96
|
+
const onLoadMore = vi.fn();
|
|
97
|
+
render(
|
|
98
|
+
<InfiniteScroll hasMore={true} onLoadMore={onLoadMore}>
|
|
99
|
+
<p>Content</p>
|
|
100
|
+
</InfiniteScroll>,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
act(() => {
|
|
104
|
+
observerCallback(
|
|
105
|
+
[{ isIntersecting: false } as IntersectionObserverEntry],
|
|
106
|
+
{} as IntersectionObserver,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(onLoadMore).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders loading slot when loading', () => {
|
|
114
|
+
render(
|
|
115
|
+
<InfiniteScroll
|
|
116
|
+
hasMore={true}
|
|
117
|
+
loading={true}
|
|
118
|
+
onLoadMore={() => {}}
|
|
119
|
+
renderLoading={() => <p>Loading...</p>}
|
|
120
|
+
>
|
|
121
|
+
<p>Content</p>
|
|
122
|
+
</InfiniteScroll>,
|
|
123
|
+
);
|
|
124
|
+
expect(screen.getByText('Loading...')).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders end slot when hasMore is false', () => {
|
|
128
|
+
render(
|
|
129
|
+
<InfiniteScroll
|
|
130
|
+
hasMore={false}
|
|
131
|
+
onLoadMore={() => {}}
|
|
132
|
+
renderEnd={() => <p>No more items</p>}
|
|
133
|
+
>
|
|
134
|
+
<p>Content</p>
|
|
135
|
+
</InfiniteScroll>,
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByText('No more items')).toBeDefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('applies className', () => {
|
|
141
|
+
const { container } = render(
|
|
142
|
+
<InfiniteScroll hasMore={false} onLoadMore={() => {}} className="my-scroll">
|
|
143
|
+
<p>Content</p>
|
|
144
|
+
</InfiniteScroll>,
|
|
145
|
+
);
|
|
146
|
+
expect(container.querySelector('.my-scroll')).not.toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('disconnects observer on unmount', () => {
|
|
150
|
+
const { unmount } = render(
|
|
151
|
+
<InfiniteScroll hasMore={true} onLoadMore={() => {}}>
|
|
152
|
+
<p>Content</p>
|
|
153
|
+
</InfiniteScroll>,
|
|
154
|
+
);
|
|
155
|
+
unmount();
|
|
156
|
+
expect(mockDisconnect).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles missing IntersectionObserver (SSR guard)', () => {
|
|
160
|
+
const saved = global.IntersectionObserver;
|
|
161
|
+
delete (global as any).IntersectionObserver;
|
|
162
|
+
|
|
163
|
+
// Should not throw
|
|
164
|
+
const { container } = render(
|
|
165
|
+
<InfiniteScroll hasMore={true} onLoadMore={() => {}}>
|
|
166
|
+
<p>Content</p>
|
|
167
|
+
</InfiniteScroll>,
|
|
168
|
+
);
|
|
169
|
+
expect(screen.getByText('Content')).toBeDefined();
|
|
170
|
+
|
|
171
|
+
global.IntersectionObserver = saved;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('direction prop', () => {
|
|
175
|
+
it('direction="up" applies column-reverse style', () => {
|
|
176
|
+
const { container } = render(
|
|
177
|
+
<InfiniteScroll hasMore={true} onLoadMore={() => {}} direction="up">
|
|
178
|
+
<p>Content</p>
|
|
179
|
+
</InfiniteScroll>,
|
|
180
|
+
);
|
|
181
|
+
const wrapper = container.querySelector('[data-component="infinite-scroll"]') as HTMLElement;
|
|
182
|
+
expect(wrapper.style.display).toBe('flex');
|
|
183
|
+
expect(wrapper.style.flexDirection).toBe('column-reverse');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('default direction has no inline style', () => {
|
|
187
|
+
const { container } = render(
|
|
188
|
+
<InfiniteScroll hasMore={true} onLoadMore={() => {}}>
|
|
189
|
+
<p>Content</p>
|
|
190
|
+
</InfiniteScroll>,
|
|
191
|
+
);
|
|
192
|
+
const wrapper = container.querySelector('[data-component="infinite-scroll"]') as HTMLElement;
|
|
193
|
+
expect(wrapper.style.display).toBe('');
|
|
194
|
+
expect(wrapper.style.flexDirection).toBe('');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('sentinel still observed and fires onLoadMore in direction="up"', () => {
|
|
198
|
+
const onLoadMore = vi.fn();
|
|
199
|
+
const { container } = render(
|
|
200
|
+
<InfiniteScroll hasMore={true} onLoadMore={onLoadMore} direction="up">
|
|
201
|
+
<p>Content</p>
|
|
202
|
+
</InfiniteScroll>,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(container.querySelector('[data-sentinel]')).not.toBeNull();
|
|
206
|
+
expect(mockObserve).toHaveBeenCalled();
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
observerCallback(
|
|
210
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
211
|
+
{} as IntersectionObserver,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useRef, useEffect, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Props for the InfiniteScroll headless component. */
|
|
4
|
+
export interface InfiniteScrollProps {
|
|
5
|
+
hasMore: boolean;
|
|
6
|
+
loading?: boolean;
|
|
7
|
+
onLoadMore: () => void;
|
|
8
|
+
threshold?: number;
|
|
9
|
+
rootMargin?: string;
|
|
10
|
+
direction?: 'down' | 'up';
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
renderLoading?: () => ReactNode;
|
|
13
|
+
renderEnd?: () => ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Headless infinite scroll wrapper using IntersectionObserver.
|
|
19
|
+
* Renders a sentinel element that triggers `onLoadMore` when visible.
|
|
20
|
+
* Use `direction="up"` for reverse-scroll chat UIs.
|
|
21
|
+
*/
|
|
22
|
+
export function InfiniteScroll({
|
|
23
|
+
hasMore,
|
|
24
|
+
loading = false,
|
|
25
|
+
onLoadMore,
|
|
26
|
+
threshold = 0.1,
|
|
27
|
+
rootMargin = '0px',
|
|
28
|
+
direction = 'down',
|
|
29
|
+
children,
|
|
30
|
+
renderLoading,
|
|
31
|
+
renderEnd,
|
|
32
|
+
className,
|
|
33
|
+
}: InfiniteScrollProps) {
|
|
34
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const onLoadMoreRef = useRef(onLoadMore);
|
|
36
|
+
onLoadMoreRef.current = onLoadMore;
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
40
|
+
const sentinel = sentinelRef.current;
|
|
41
|
+
if (!sentinel) return;
|
|
42
|
+
|
|
43
|
+
const observer = new IntersectionObserver(
|
|
44
|
+
(entries) => {
|
|
45
|
+
if (entries[0]?.isIntersecting) {
|
|
46
|
+
onLoadMoreRef.current();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{ threshold, rootMargin },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
observer.observe(sentinel);
|
|
53
|
+
return () => observer.disconnect();
|
|
54
|
+
}, [threshold, rootMargin, hasMore, loading]);
|
|
55
|
+
|
|
56
|
+
const style = direction === 'up'
|
|
57
|
+
? { display: 'flex', flexDirection: 'column-reverse' as const }
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div data-component="infinite-scroll" className={className} style={style}>
|
|
62
|
+
{children}
|
|
63
|
+
{hasMore && !loading && (
|
|
64
|
+
<div ref={sentinelRef} aria-hidden="true" data-sentinel />
|
|
65
|
+
)}
|
|
66
|
+
{loading && renderLoading?.()}
|
|
67
|
+
{!hasMore && renderEnd?.()}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|