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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +10 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. 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
+ }