@prmichaelsen/acp-visualizer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# Pagination Suite
|
|
2
|
+
|
|
3
|
+
**Category**: Design
|
|
4
|
+
**Applicable To**: All paginated data display — explicit page controls, infinite scroll, virtualized lists, offset/limit APIs, and view mode toggles
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
The pagination suite provides four complementary strategies for navigating large data sets: Paginator (explicit page controls with editable current page), PaginationToggle (paginated vs infinite mode switch with page-size slider), InfiniteScrollSentinel (auto-load on scroll via IntersectionObserver), and react-virtuoso / @tanstack/react-virtual for virtualized rendering. All feed APIs use a consistent offset/limit pattern with `{ items, total, hasMore }` responses.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## When to Use This Pattern
|
|
16
|
+
|
|
17
|
+
| Strategy | When to Use |
|
|
18
|
+
|---|---|
|
|
19
|
+
| **Paginator** | Discrete page navigation with known total pages (e.g., reorder grid) |
|
|
20
|
+
| **PaginationToggle** | User should choose between paginated and infinite modes |
|
|
21
|
+
| **Virtuoso `useWindowScroll`** | Page-level feed with infinite scroll (memories, spaces, profiles) |
|
|
22
|
+
| **Virtuoso container-scroll** | Fixed-height container with prepend (chat messages) |
|
|
23
|
+
| **@tanstack/react-virtual** | Contained scrollable list within a panel (comments) |
|
|
24
|
+
| **InfiniteScrollSentinel** | Simple auto-load trigger without full virtualization |
|
|
25
|
+
| **FeedList (non-virtualized)** | Small static lists (< ~50 items) with loading/empty/error states |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Core Principles
|
|
30
|
+
|
|
31
|
+
1. **Offset/Limit API Convention**: All paginated endpoints accept `limit` + `offset` and return `{ items, total, hasMore }`
|
|
32
|
+
2. **Clamped Limits**: Server clamps `limit` to `[1, 50]` — client defaults to `PAGE_SIZE = 20`
|
|
33
|
+
3. **Offset Ref**: Client tracks current offset in a `useRef` (not state) to avoid stale closures in callbacks
|
|
34
|
+
4. **Cache First Pages**: First-page results are cached for instant tab/filter switching; load-more appends are not cached
|
|
35
|
+
5. **URL State for Pagination**: Page number, page size, and view mode sync to URL search params via `replaceState`
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Implementation
|
|
40
|
+
|
|
41
|
+
### Paginator (Explicit Page Controls)
|
|
42
|
+
|
|
43
|
+
**File**: `src/components/Paginator.tsx`
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
interface PaginatorProps {
|
|
47
|
+
currentPage: number
|
|
48
|
+
totalPages: number
|
|
49
|
+
onPageChange: (page: number) => void
|
|
50
|
+
/** Number of sibling page numbers on each side of current (default: 2) */
|
|
51
|
+
siblings?: number
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Layout**: `|< < 3 4 [X] 5 6 > >|`
|
|
56
|
+
|
|
57
|
+
- **First/Last**: `ChevronsLeft` / `ChevronsRight` — jump to page 1 or totalPages
|
|
58
|
+
- **Prev/Next**: `ChevronLeft` / `ChevronRight` — single page step
|
|
59
|
+
- **Siblings**: Clickable page numbers ±`siblings` from current
|
|
60
|
+
- **Editable current page**: Gradient input (`from-purple-600 to-blue-600`)
|
|
61
|
+
- Auto-selects on focus
|
|
62
|
+
- Enter commits, Escape reverts, blur commits
|
|
63
|
+
- `inputMode="numeric"` for mobile keyboards
|
|
64
|
+
- Value clamped to `[1, totalPages]`
|
|
65
|
+
- **Hidden**: Returns `null` if `totalPages <= 1`
|
|
66
|
+
- **Accessibility**: `aria-label` on all buttons and input
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### PaginationToggle (Mode Switch + Page Size)
|
|
71
|
+
|
|
72
|
+
**File**: `src/components/reorder/PaginationToggle.tsx`
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
interface PaginationToggleProps {
|
|
76
|
+
viewMode: 'infinite' | 'paginated'
|
|
77
|
+
onViewModeChange: (mode: 'infinite' | 'paginated') => void
|
|
78
|
+
currentPage?: number
|
|
79
|
+
totalPages?: number
|
|
80
|
+
onPageChange?: (page: number) => void
|
|
81
|
+
pageSize?: number
|
|
82
|
+
onPageSizeChange?: (size: number) => void
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Features**:
|
|
87
|
+
- **View mode pills**: "Pages" | "Infinite" toggle buttons
|
|
88
|
+
- Active: gradient `from-purple-600 to-blue-600` with shadow
|
|
89
|
+
- Inactive: gray with hover
|
|
90
|
+
- **Paginated mode shows**:
|
|
91
|
+
- Page size slider (discrete options: `[5, 50, 70, 100]`) with "Per page: N" label
|
|
92
|
+
- Paginator component for page navigation
|
|
93
|
+
- **Page size change resets to page 1**
|
|
94
|
+
|
|
95
|
+
**Usage**:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
<PaginationToggle
|
|
99
|
+
viewMode={viewMode}
|
|
100
|
+
onViewModeChange={setViewMode}
|
|
101
|
+
currentPage={currentPage}
|
|
102
|
+
totalPages={totalPages}
|
|
103
|
+
onPageChange={setCurrentPage}
|
|
104
|
+
pageSize={pageSize}
|
|
105
|
+
onPageSizeChange={(size) => {
|
|
106
|
+
setPageSize(size)
|
|
107
|
+
setCurrentPage(1) // Reset on size change
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
### InfiniteScrollSentinel (Auto-Load Trigger)
|
|
115
|
+
|
|
116
|
+
**File**: `src/components/feed/InfiniteScrollSentinel.tsx`
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
interface InfiniteScrollSentinelProps {
|
|
120
|
+
hasMore: boolean
|
|
121
|
+
loading: boolean
|
|
122
|
+
onLoadMore: () => void
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Implementation**:
|
|
127
|
+
- Renders a sentinel `<div>` (h-4) watched by IntersectionObserver (threshold: 0.1)
|
|
128
|
+
- Triggers `onLoadMore` when sentinel becomes 10% visible AND `hasMore && !loading`
|
|
129
|
+
- Shows `<Loader2 animate-spin>` during loading
|
|
130
|
+
- Place at the bottom of a scrollable list
|
|
131
|
+
|
|
132
|
+
**Usage**:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
{memories.map(m => <MemoryCard key={m.id} memory={m} />)}
|
|
136
|
+
<InfiniteScrollSentinel hasMore={hasMore} loading={loadingMore} onLoadMore={() => loadFeed(false)} />
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### Virtuoso (react-virtuoso) — Feed & Chat Patterns
|
|
142
|
+
|
|
143
|
+
#### Pattern A: Window-Scroll Feed
|
|
144
|
+
|
|
145
|
+
Used by: memories, SpacesFeed, ProfileMemoriesFeed, GroupMemories
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { Virtuoso } from 'react-virtuoso'
|
|
149
|
+
|
|
150
|
+
<Virtuoso
|
|
151
|
+
useWindowScroll
|
|
152
|
+
data={memories}
|
|
153
|
+
endReached={() => {
|
|
154
|
+
if (hasMore && !loading && !loadingMore) {
|
|
155
|
+
loadFeed(false)
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
itemContent={(index, memory) => (
|
|
159
|
+
<div className="pb-2">
|
|
160
|
+
<MemoryCard memory={memory} />
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
components={{
|
|
164
|
+
Footer: () => loadingMore ? (
|
|
165
|
+
<div className="flex justify-center py-4">
|
|
166
|
+
<Loader2 className="w-5 h-5 animate-spin text-gray-500" />
|
|
167
|
+
</div>
|
|
168
|
+
) : null,
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
#### Pattern B: Container-Scroll Chat (Prepend)
|
|
174
|
+
|
|
175
|
+
Used by: MessageList
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
|
179
|
+
|
|
180
|
+
const INITIAL_INDEX = 100_000
|
|
181
|
+
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
|
182
|
+
const [firstItemIndex, setFirstItemIndex] = useState(INITIAL_INDEX - items.length)
|
|
183
|
+
|
|
184
|
+
// Update firstItemIndex when items prepend
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
setFirstItemIndex(INITIAL_INDEX - items.length)
|
|
187
|
+
}, [items.length])
|
|
188
|
+
|
|
189
|
+
<Virtuoso
|
|
190
|
+
ref={virtuosoRef}
|
|
191
|
+
className="flex-grow h-0"
|
|
192
|
+
firstItemIndex={firstItemIndex}
|
|
193
|
+
initialTopMostItemIndex={items.length - 1}
|
|
194
|
+
data={items}
|
|
195
|
+
startReached={() => {
|
|
196
|
+
if (!isLoadingMore && hasMore && onLoadMore) {
|
|
197
|
+
setIsLoadingMore(true)
|
|
198
|
+
onLoadMore()
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
itemContent={(index, item) => <Message ... />}
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
// Programmatic scroll
|
|
205
|
+
virtuosoRef.current?.scrollToIndex({ index: 'LAST', behavior: 'smooth' })
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### Pattern C: @tanstack/react-virtual (Container Panel)
|
|
209
|
+
|
|
210
|
+
Used by: CommentSection
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
214
|
+
|
|
215
|
+
const virtualizer = useVirtualizer({
|
|
216
|
+
count: comments.length,
|
|
217
|
+
getScrollElement: () => scrollContainerRef.current,
|
|
218
|
+
estimateSize: () => 160,
|
|
219
|
+
overscan: 10,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// Manual scroll detection for load-more
|
|
223
|
+
const handleScroll = () => {
|
|
224
|
+
const el = scrollContainerRef.current
|
|
225
|
+
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
|
226
|
+
if (distFromBottom < 100 && hasMore && !loadingMore) loadMore()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Programmatic scroll
|
|
230
|
+
virtualizer.scrollToIndex(index, { align: 'start' })
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
### Offset/Limit API Convention
|
|
236
|
+
|
|
237
|
+
All paginated endpoints follow this pattern:
|
|
238
|
+
|
|
239
|
+
**Request**:
|
|
240
|
+
```
|
|
241
|
+
GET /api/spaces/feed?limit=20&offset=0&algorithm=smart&query=AI
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Response**:
|
|
245
|
+
```typescript
|
|
246
|
+
{
|
|
247
|
+
memories: MemoryItem[] // The page of results
|
|
248
|
+
total: number // Total matching items
|
|
249
|
+
hasMore: boolean // Whether more pages exist
|
|
250
|
+
limit: number // Echoed back
|
|
251
|
+
offset: number // Echoed back
|
|
252
|
+
maps?: {
|
|
253
|
+
profiles: Record<string, UserProfile> // Profile enrichment
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Server-side clamping**:
|
|
259
|
+
```typescript
|
|
260
|
+
const limit = Math.min(Math.max(parsedLimit || 20, 1), 50) // Clamp to [1, 50]
|
|
261
|
+
const offset = Math.max(parsedOffset || 0, 0)
|
|
262
|
+
const hasMore = offset + results.length < total
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### Client-Side Data Fetching Pattern
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
const PAGE_SIZE = 20
|
|
271
|
+
const offsetRef = useRef(0)
|
|
272
|
+
const [memories, setMemories] = useState<MemoryItem[]>([])
|
|
273
|
+
const [loading, setLoading] = useState(false)
|
|
274
|
+
const [loadingMore, setLoadingMore] = useState(false)
|
|
275
|
+
const [hasMore, setHasMore] = useState(false)
|
|
276
|
+
|
|
277
|
+
const loadFeed = useCallback(async (reset: boolean) => {
|
|
278
|
+
const currentOffset = reset ? 0 : offsetRef.current
|
|
279
|
+
|
|
280
|
+
if (reset) {
|
|
281
|
+
setLoading(true)
|
|
282
|
+
setMemories([])
|
|
283
|
+
offsetRef.current = 0
|
|
284
|
+
} else {
|
|
285
|
+
setLoadingMore(true)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const result = await FeedService.getFeed({
|
|
290
|
+
limit: PAGE_SIZE,
|
|
291
|
+
offset: currentOffset,
|
|
292
|
+
// ... filters
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const newItems = result.memories ?? []
|
|
296
|
+
setMemories(prev => reset ? newItems : [...prev, ...newItems])
|
|
297
|
+
setHasMore(result.hasMore ?? false)
|
|
298
|
+
offsetRef.current = currentOffset + newItems.length
|
|
299
|
+
|
|
300
|
+
// Cache first page for instant tab switching
|
|
301
|
+
if (reset) {
|
|
302
|
+
cache.set(cacheKey, { memories: newItems, total: result.total, hasMore: result.hasMore })
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
setLoading(false)
|
|
306
|
+
setLoadingMore(false)
|
|
307
|
+
}
|
|
308
|
+
}, [filters])
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**Key details**:
|
|
312
|
+
- `offsetRef` (not state) avoids stale closure issues in `endReached` / `loadFeed` callbacks
|
|
313
|
+
- Reset clears items and resets offset to 0
|
|
314
|
+
- Append keeps existing items and advances offset
|
|
315
|
+
- Only first-page (reset) results are cached
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
### URL State Sync
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
const url = new URL(window.location.href)
|
|
324
|
+
if (viewMode === 'paginated') {
|
|
325
|
+
url.searchParams.set('mode', 'pages')
|
|
326
|
+
url.searchParams.set('page', String(currentPage))
|
|
327
|
+
if (pageSize !== DEFAULT_PAGE_SIZE) {
|
|
328
|
+
url.searchParams.set('pageSize', String(pageSize))
|
|
329
|
+
} else {
|
|
330
|
+
url.searchParams.delete('pageSize')
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
url.searchParams.delete('mode')
|
|
334
|
+
url.searchParams.delete('page')
|
|
335
|
+
url.searchParams.delete('pageSize')
|
|
336
|
+
}
|
|
337
|
+
window.history.replaceState(null, '', url.toString())
|
|
338
|
+
}, [currentPage, viewMode, pageSize])
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Anti-Patterns
|
|
344
|
+
|
|
345
|
+
### Using State Instead of Ref for Offset
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
// Bad: Stale closure — endReached captures old offset value
|
|
349
|
+
const [offset, setOffset] = useState(0)
|
|
350
|
+
endReached={() => loadFeed(offset)} // offset is stale!
|
|
351
|
+
|
|
352
|
+
// Good: Ref always has current value
|
|
353
|
+
const offsetRef = useRef(0)
|
|
354
|
+
endReached={() => loadFeed(offsetRef.current)}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Unbounded API Queries
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
// Bad: No limit — could return thousands of documents
|
|
361
|
+
const result = await fetch('/api/feed')
|
|
362
|
+
|
|
363
|
+
// Good: Always pass limit, server clamps to [1, 50]
|
|
364
|
+
const result = await fetch('/api/feed?limit=20&offset=0')
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Not Resetting Page on Filter Change
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// Bad: Page 5 of old filter shows empty results
|
|
371
|
+
setAlgorithm('recent')
|
|
372
|
+
// currentPage still 5, but 'recent' might only have 3 pages
|
|
373
|
+
|
|
374
|
+
// Good: Reset to page 1 on any filter change
|
|
375
|
+
setAlgorithm('recent')
|
|
376
|
+
setCurrentPage(1)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Caching Load-More Results
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
// Bad: Cache includes appended pages — stale on next visit
|
|
383
|
+
cache.set(key, { memories: allLoadedMemories })
|
|
384
|
+
|
|
385
|
+
// Good: Only cache first-page results
|
|
386
|
+
if (reset) {
|
|
387
|
+
cache.set(key, { memories: firstPageMemories, total, hasMore })
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## Key Design Decisions
|
|
394
|
+
|
|
395
|
+
### Architecture
|
|
396
|
+
|
|
397
|
+
| Decision | Choice | Rationale |
|
|
398
|
+
|---|---|---|
|
|
399
|
+
| API pagination style | Offset/limit (not cursor) | Simpler; works with Firestore's `startAfter` |
|
|
400
|
+
| Server limit clamp | [1, 50] | Prevents abuse; 50 is enough for any page |
|
|
401
|
+
| Client default page size | 20 | Good balance between load time and content density |
|
|
402
|
+
| Offset tracking | `useRef` (not state) | Avoids stale closures in scroll callbacks |
|
|
403
|
+
| First-page caching | Cache only reset fetches | Prevents stale data from cached load-more appends |
|
|
404
|
+
|
|
405
|
+
### Component Selection
|
|
406
|
+
|
|
407
|
+
| Decision | Choice | Rationale |
|
|
408
|
+
|---|---|---|
|
|
409
|
+
| Feed virtualization | react-virtuoso | Best window-scroll support, prepend support for chat |
|
|
410
|
+
| Panel virtualization | @tanstack/react-virtual | Lighter weight for contained panels |
|
|
411
|
+
| Simple lists | FeedList (no virtualization) | < 50 items don't need virtual DOM overhead |
|
|
412
|
+
| Auto-load trigger | IntersectionObserver sentinel | No scroll listener needed; clean observer API |
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Checklist
|
|
417
|
+
|
|
418
|
+
- [ ] API endpoints clamp limit to `[1, 50]` and return `{ items, total, hasMore }`
|
|
419
|
+
- [ ] Client tracks offset in `useRef`, not `useState`
|
|
420
|
+
- [ ] Only first-page (reset) results are cached; load-more appends are not
|
|
421
|
+
- [ ] Page/filter changes reset offset to 0 and clear existing items
|
|
422
|
+
- [ ] Paginator returns `null` when `totalPages <= 1`
|
|
423
|
+
- [ ] PaginationToggle resets to page 1 when page size changes
|
|
424
|
+
- [ ] Virtuoso feeds use `useWindowScroll` for page-level scroll
|
|
425
|
+
- [ ] Virtuoso chat uses `firstItemIndex` pattern for stable prepend
|
|
426
|
+
- [ ] InfiniteScrollSentinel placed at list bottom with `hasMore` + `loading` guards
|
|
427
|
+
- [ ] Pagination state synced to URL via `replaceState` for deep-linkability
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## Related Patterns
|
|
432
|
+
|
|
433
|
+
- **[Card & List](./tanstack-cloudflare.card-and-list.md)**: FeedList for non-virtualized lists, Virtuoso usage details
|
|
434
|
+
- **[Form Controls](./tanstack-cloudflare.form-controls.md)**: Slider component used in PaginationToggle for page size
|
|
435
|
+
- **[SSR Preload](./ssr-preload.md)**: Server-side first-page preload seeds the Virtuoso data array
|
|
436
|
+
- **[Feed State Preservation](./local.feed-state-preservation.md)**: FeedCacheContext preserves feed state across navigation
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
**Status**: Stable
|
|
441
|
+
**Last Updated**: 2026-03-14
|
|
442
|
+
**Contributors**: Community
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# PillInput & Multi-Typeahead Selector
|
|
2
|
+
|
|
3
|
+
**Category**: Design
|
|
4
|
+
**Applicable To**: Multi-value inputs with typeahead suggestions, tag selection, and filterable option lists
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
PillInput is a multi-value text input with Fuse.js fuzzy typeahead, removable pill badges, custom entry support, and keyboard navigation. It supports both selecting from a predefined options list and entering custom free-text values. Used for tag filters, space selectors, and multi-value search inputs.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Implementation
|
|
16
|
+
|
|
17
|
+
### PillInput
|
|
18
|
+
|
|
19
|
+
**File**: `src/components/feed/PillInput.tsx`
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
interface PillInputProps {
|
|
23
|
+
options: string[] // Available options for typeahead
|
|
24
|
+
selected: string[] // Currently selected values
|
|
25
|
+
onChange: (selected: string[]) => void
|
|
26
|
+
placeholder?: string
|
|
27
|
+
allowCustom?: boolean // Allow free-text entries (default: true)
|
|
28
|
+
maxResults?: number // Max suggestions shown (default: 8)
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Features**:
|
|
33
|
+
|
|
34
|
+
1. **Typeahead with Fuse.js** (threshold: 0.4):
|
|
35
|
+
- Filters options list as user types
|
|
36
|
+
- Removes already-selected values from suggestions
|
|
37
|
+
- Shows up to `maxResults` matches
|
|
38
|
+
|
|
39
|
+
2. **Custom Entry** (when `allowCustom: true`):
|
|
40
|
+
- Enter or comma adds current text as custom pill
|
|
41
|
+
- Trims whitespace, prevents duplicates
|
|
42
|
+
|
|
43
|
+
3. **Keyboard Navigation**:
|
|
44
|
+
- ArrowUp/ArrowDown: navigate dropdown suggestions
|
|
45
|
+
- Enter: select highlighted suggestion (or add custom)
|
|
46
|
+
- Escape: close dropdown
|
|
47
|
+
- Backspace on empty input: remove last pill
|
|
48
|
+
|
|
49
|
+
4. **Pill Display**:
|
|
50
|
+
- Gradient background: `from-purple-500 to-blue-500`
|
|
51
|
+
- X button to remove individual pills
|
|
52
|
+
- Flex wrap layout for multiple pills
|
|
53
|
+
|
|
54
|
+
5. **Dropdown**:
|
|
55
|
+
- Positioned below input
|
|
56
|
+
- Max 8 results
|
|
57
|
+
- Highlighted item via keyboard or hover
|
|
58
|
+
- Click outside closes dropdown
|
|
59
|
+
|
|
60
|
+
**Usage**:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
const [tags, setTags] = useState<string[]>([])
|
|
64
|
+
|
|
65
|
+
<PillInput
|
|
66
|
+
options={['javascript', 'typescript', 'python', 'rust', 'go']}
|
|
67
|
+
selected={tags}
|
|
68
|
+
onChange={setTags}
|
|
69
|
+
placeholder="Add tags..."
|
|
70
|
+
allowCustom={true}
|
|
71
|
+
/>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Rendered Output**:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
┌─────────────────────────────────────────────┐
|
|
78
|
+
│ [javascript ×] [rust ×] type here... │
|
|
79
|
+
├─────────────────────────────────────────────┤
|
|
80
|
+
│ ▸ typescript │
|
|
81
|
+
│ python │
|
|
82
|
+
│ go │
|
|
83
|
+
└─────────────────────────────────────────────┘
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### Multi-Typeahead Pattern (Generalized)
|
|
89
|
+
|
|
90
|
+
For more complex multi-select scenarios beyond string arrays, use this generalized approach:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
interface TypeaheadOption<T> {
|
|
94
|
+
id: string
|
|
95
|
+
label: string
|
|
96
|
+
data: T
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function useMultiTypeahead<T>(options: TypeaheadOption<T>[], selected: string[]) {
|
|
100
|
+
const [query, setQuery] = useState('')
|
|
101
|
+
const [highlightIndex, setHighlightIndex] = useState(-1)
|
|
102
|
+
|
|
103
|
+
const fuse = useMemo(() => new Fuse(options, {
|
|
104
|
+
keys: ['label'],
|
|
105
|
+
threshold: 0.4,
|
|
106
|
+
}), [options])
|
|
107
|
+
|
|
108
|
+
const suggestions = useMemo(() => {
|
|
109
|
+
const base = query
|
|
110
|
+
? fuse.search(query).map(r => r.item)
|
|
111
|
+
: options
|
|
112
|
+
return base
|
|
113
|
+
.filter(opt => !selected.includes(opt.id))
|
|
114
|
+
.slice(0, 8)
|
|
115
|
+
}, [query, selected, fuse, options])
|
|
116
|
+
|
|
117
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
118
|
+
switch (e.key) {
|
|
119
|
+
case 'ArrowDown':
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
setHighlightIndex(i => Math.min(suggestions.length - 1, i + 1))
|
|
122
|
+
break
|
|
123
|
+
case 'ArrowUp':
|
|
124
|
+
e.preventDefault()
|
|
125
|
+
setHighlightIndex(i => Math.max(0, i - 1))
|
|
126
|
+
break
|
|
127
|
+
case 'Enter':
|
|
128
|
+
e.preventDefault()
|
|
129
|
+
if (highlightIndex >= 0) selectOption(suggestions[highlightIndex])
|
|
130
|
+
break
|
|
131
|
+
case 'Escape':
|
|
132
|
+
setQuery('')
|
|
133
|
+
break
|
|
134
|
+
case 'Backspace':
|
|
135
|
+
if (!query && selected.length) removeLastSelected()
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { query, setQuery, suggestions, highlightIndex, handleKeyDown }
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Usage with complex objects**:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const { query, setQuery, suggestions, highlightIndex, handleKeyDown } = useMultiTypeahead(
|
|
148
|
+
users.map(u => ({ id: u.uid, label: u.displayName, data: u })),
|
|
149
|
+
selectedUserIds
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
<input value={query} onChange={e => setQuery(e.target.value)} onKeyDown={handleKeyDown} />
|
|
153
|
+
{suggestions.map((opt, i) => (
|
|
154
|
+
<div key={opt.id}
|
|
155
|
+
className={i === highlightIndex ? 'bg-gray-700' : ''}
|
|
156
|
+
onClick={() => addSelection(opt.id)}>
|
|
157
|
+
<Avatar user={opt.data} />
|
|
158
|
+
<span>{opt.label}</span>
|
|
159
|
+
</div>
|
|
160
|
+
))}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Anti-Patterns
|
|
166
|
+
|
|
167
|
+
### Using Select/Multiselect Instead of PillInput
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// Bad: Native multi-select is ugly and hard to use on mobile
|
|
171
|
+
<select multiple>{options.map(o => <option>{o}</option>)}</select>
|
|
172
|
+
|
|
173
|
+
// Good: PillInput with typeahead for better UX
|
|
174
|
+
<PillInput options={options} selected={selected} onChange={setSelected} />
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Not Filtering Already-Selected Options
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// Bad: User sees already-selected items in dropdown (confusing)
|
|
181
|
+
const suggestions = fuse.search(query)
|
|
182
|
+
|
|
183
|
+
// Good: Remove selected from suggestions
|
|
184
|
+
const suggestions = fuse.search(query).filter(r => !selected.includes(r.item))
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Forgetting Backspace-to-Remove
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// Bad: No way to remove last pill from keyboard
|
|
191
|
+
onKeyDown={(e) => { if (e.key === 'Enter') addPill() }}
|
|
192
|
+
|
|
193
|
+
// Good: Backspace on empty input removes last pill
|
|
194
|
+
onKeyDown={(e) => {
|
|
195
|
+
if (e.key === 'Backspace' && !query && selected.length) {
|
|
196
|
+
removeLastPill()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Checklist
|
|
204
|
+
|
|
205
|
+
- [ ] Use Fuse.js for fuzzy matching (threshold: 0.4)
|
|
206
|
+
- [ ] Filter already-selected values from suggestions
|
|
207
|
+
- [ ] Support ArrowUp/Down for dropdown navigation
|
|
208
|
+
- [ ] Support Enter to select highlighted or add custom
|
|
209
|
+
- [ ] Support Backspace on empty to remove last pill
|
|
210
|
+
- [ ] Support Escape to close dropdown
|
|
211
|
+
- [ ] Click outside closes dropdown
|
|
212
|
+
- [ ] Max 8 results in dropdown to prevent overwhelming
|
|
213
|
+
- [ ] Pills show gradient background with X remove button
|
|
214
|
+
- [ ] Trim whitespace and prevent duplicate entries
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
**Status**: Stable
|
|
219
|
+
**Last Updated**: 2026-03-14
|
|
220
|
+
**Contributors**: Community
|