mtrl-addons 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +28 -230
- package/CLAUDE.md +882 -0
- package/build.js +286 -110
- package/package.json +2 -1
- package/src/components/vlist/features/api.ts +316 -12
- package/src/components/vlist/features/selection.ts +248 -256
- package/src/components/vlist/features/viewport.ts +1 -7
- package/src/components/vlist/index.ts +1 -0
- package/src/components/vlist/types.ts +140 -8
- package/src/core/layout/schema.ts +81 -38
- package/src/core/viewport/constants.ts +7 -2
- package/src/core/viewport/features/collection.ts +376 -76
- package/src/core/viewport/features/item-size.ts +4 -4
- package/src/core/viewport/features/momentum.ts +11 -2
- package/src/core/viewport/features/rendering.ts +424 -30
- package/src/core/viewport/features/scrolling.ts +41 -25
- package/src/core/viewport/features/utils.ts +11 -5
- package/src/core/viewport/features/virtual.ts +169 -28
- package/src/core/viewport/types.ts +2 -2
- package/src/core/viewport/viewport.ts +29 -10
- package/src/styles/components/_vlist.scss +234 -213
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* Provides a clean public API for the VList component
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { VListConfig, VListItem } from "../types";
|
|
6
|
+
import type { VListConfig, VListItem, RemoveItemOptions } from "../types";
|
|
7
|
+
|
|
8
|
+
// Re-export for convenience
|
|
9
|
+
export type { RemoveItemOptions } from "../types";
|
|
7
10
|
|
|
8
|
-
/**
|
|
9
|
-
* Adds public API methods to VList
|
|
10
|
-
*/
|
|
11
11
|
export const withAPI = <T extends VListItem = VListItem>(
|
|
12
|
-
config: VListConfig<T
|
|
12
|
+
config: VListConfig<T>,
|
|
13
13
|
) => {
|
|
14
14
|
return (component: any) => {
|
|
15
15
|
// Initialize viewport on creation
|
|
@@ -23,6 +23,10 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
23
23
|
let isLoading = false;
|
|
24
24
|
let selectedIds = new Set<string | number>();
|
|
25
25
|
|
|
26
|
+
// Track pending removals to filter out items from stale server responses
|
|
27
|
+
// This prevents race conditions where server returns old data before updates are committed
|
|
28
|
+
const pendingRemovals = new Set<string | number>();
|
|
29
|
+
|
|
26
30
|
// Listen for collection events
|
|
27
31
|
component.on?.("collection:range-loaded", () => {
|
|
28
32
|
isLoading = false;
|
|
@@ -69,12 +73,276 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
69
73
|
return component.totalItems || component.items?.length || 0;
|
|
70
74
|
},
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Get item at a specific index
|
|
78
|
+
* @param index - The index of the item to retrieve
|
|
79
|
+
* @returns The item at the index, or undefined if not found
|
|
80
|
+
*/
|
|
81
|
+
getItem(index: number): T | undefined {
|
|
82
|
+
if ((component as any).collection?.getItem) {
|
|
83
|
+
return (component as any).collection.getItem(index);
|
|
84
|
+
}
|
|
85
|
+
const items = this.getItems();
|
|
86
|
+
return items[index];
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Update item at a specific index
|
|
91
|
+
* @param index - The index of the item to update
|
|
92
|
+
* @param item - The new item data (full replacement)
|
|
93
|
+
*/
|
|
94
|
+
updateItem(index: number, item: T): void {
|
|
95
|
+
const items = this.getItems();
|
|
96
|
+
|
|
97
|
+
if (index < 0 || index >= items.length) {
|
|
98
|
+
console.warn(`[VList] updateItem: index ${index} out of bounds`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const previousItem = items[index];
|
|
103
|
+
|
|
104
|
+
// Update in collection items array
|
|
105
|
+
if ((component as any).collection?.items) {
|
|
106
|
+
(component as any).collection.items[index] = item;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Emit event for rendering feature to handle DOM update
|
|
110
|
+
component.emit?.("item:update-request", {
|
|
111
|
+
index,
|
|
112
|
+
item,
|
|
113
|
+
previousItem,
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Update item by ID
|
|
119
|
+
* Finds the item in the collection by its ID and updates it with new data
|
|
120
|
+
* Re-renders the item if currently visible in the viewport
|
|
121
|
+
* @param id - The item ID to find
|
|
122
|
+
* @param data - Partial data to merge with existing item, or full item replacement
|
|
123
|
+
* @param options - Update options
|
|
124
|
+
* @returns true if item was found and updated, false otherwise
|
|
125
|
+
*/
|
|
126
|
+
updateItemById(
|
|
127
|
+
id: string | number,
|
|
128
|
+
data: Partial<T>,
|
|
129
|
+
options: {
|
|
130
|
+
/** If true, replace the entire item instead of merging (default: false) */
|
|
131
|
+
replace?: boolean;
|
|
132
|
+
/** If true, re-render even if not visible (default: false) */
|
|
133
|
+
forceRender?: boolean;
|
|
134
|
+
} = {},
|
|
135
|
+
): boolean {
|
|
136
|
+
const { replace = false } = options;
|
|
137
|
+
const items = this.getItems();
|
|
138
|
+
|
|
139
|
+
// Find the item by ID (handle sparse arrays from pagination)
|
|
140
|
+
// Check all possible ID fields since items may have different ID structures
|
|
141
|
+
const index = items.findIndex((item: any) => {
|
|
142
|
+
if (!item) return false;
|
|
143
|
+
// Check all possible ID fields - user_id is important for profile/contact items
|
|
144
|
+
const idsToCheck = [item.id, item._id, item.user_id].filter(
|
|
145
|
+
(v) => v !== undefined && v !== null,
|
|
146
|
+
);
|
|
147
|
+
return idsToCheck.some(
|
|
148
|
+
(itemId) => itemId === id || String(itemId) === String(id),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (index === -1) {
|
|
153
|
+
console.warn(`[VList] updateItemById: item with id ${id} not found`);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const previousItem = items[index];
|
|
158
|
+
|
|
159
|
+
// Create updated item - either replace entirely or merge
|
|
160
|
+
let updatedItem: T;
|
|
161
|
+
if (replace) {
|
|
162
|
+
updatedItem = { ...data, id: previousItem.id ?? id } as T;
|
|
163
|
+
} else {
|
|
164
|
+
// Only merge properties that have actual values (not undefined)
|
|
165
|
+
// This prevents partial updates from overwriting existing data
|
|
166
|
+
updatedItem = { ...previousItem } as T;
|
|
167
|
+
for (const [key, value] of Object.entries(data)) {
|
|
168
|
+
if (value !== undefined) {
|
|
169
|
+
(updatedItem as any)[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Update in collection items array
|
|
175
|
+
if ((component as any).collection?.items) {
|
|
176
|
+
(component as any).collection.items[index] = updatedItem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Emit event for rendering feature to handle DOM update
|
|
180
|
+
component.emit?.("item:update-request", {
|
|
181
|
+
index,
|
|
182
|
+
item: updatedItem,
|
|
183
|
+
previousItem,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return true;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Remove item at a specific index
|
|
191
|
+
* Removes the item from the collection, updates totalItems, and triggers re-render
|
|
192
|
+
* @param index - The index of the item to remove
|
|
193
|
+
* @returns true if item was found and removed, false otherwise
|
|
194
|
+
*/
|
|
195
|
+
removeItem(index: number): boolean {
|
|
196
|
+
const items = this.getItems();
|
|
197
|
+
const collection = (component as any).collection;
|
|
198
|
+
|
|
199
|
+
if (index < 0 || index >= items.length) {
|
|
200
|
+
console.warn(`[VList] removeItem: index ${index} out of bounds`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const removedItem = items[index];
|
|
205
|
+
|
|
206
|
+
// Remove from collection items array (component.collection.items is the actual array)
|
|
207
|
+
if ((component as any).collection?.items) {
|
|
208
|
+
(component as any).collection.items.splice(index, 1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// IMPORTANT: Emit item:remove-request FIRST so rendering feature rebuilds collectionItems
|
|
212
|
+
// BEFORE setTotalItems triggers a render via viewport:total-items-changed
|
|
213
|
+
component.emit?.("item:remove-request", {
|
|
214
|
+
index,
|
|
215
|
+
item: removedItem,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Update totalItems - this will trigger viewport:total-items-changed which calls renderItems
|
|
219
|
+
// By now, collectionItems has been rebuilt by the item:remove-request handler
|
|
220
|
+
if (collection?.getTotalItems && collection?.setTotalItems) {
|
|
221
|
+
const currentTotal = collection.getTotalItems();
|
|
222
|
+
collection.setTotalItems(Math.max(0, currentTotal - 1));
|
|
223
|
+
} else if (component.viewport?.collection?.setTotalItems) {
|
|
224
|
+
// Fallback to viewport.collection if available
|
|
225
|
+
const currentTotal = component.viewport.collection.getTotalItems();
|
|
226
|
+
component.viewport.collection.setTotalItems(
|
|
227
|
+
Math.max(0, currentTotal - 1),
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
// Last resort: update component.totalItems directly
|
|
231
|
+
if (component.totalItems !== undefined) {
|
|
232
|
+
component.totalItems = Math.max(0, component.totalItems - 1);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return true;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Remove item by ID
|
|
241
|
+
* Finds the item in the collection by its ID and removes it
|
|
242
|
+
* Updates totalItems and triggers re-render of visible items
|
|
243
|
+
* Optionally tracks as pending removal to filter from future fetches
|
|
244
|
+
* @param id - The item ID to find and remove
|
|
245
|
+
* @param options - Remove options (trackPending, pendingTimeout)
|
|
246
|
+
* @returns true if item was found and removed, false otherwise
|
|
247
|
+
*/
|
|
248
|
+
removeItemById(
|
|
249
|
+
id: string | number,
|
|
250
|
+
options: RemoveItemOptions = {},
|
|
251
|
+
): boolean {
|
|
252
|
+
const { trackPending = true, pendingTimeout = 5000 } = options;
|
|
253
|
+
|
|
254
|
+
if (id === undefined || id === null) {
|
|
255
|
+
console.warn(`[VList] removeItemById: invalid id`);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Add to pending removals to filter from future server responses
|
|
260
|
+
if (trackPending) {
|
|
261
|
+
pendingRemovals.add(id);
|
|
262
|
+
|
|
263
|
+
// Clear from pending removals after timeout (server should have updated by then)
|
|
264
|
+
setTimeout(() => {
|
|
265
|
+
pendingRemovals.delete(id);
|
|
266
|
+
}, pendingTimeout);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const items = this.getItems();
|
|
270
|
+
|
|
271
|
+
// Find the item by ID (handle sparse arrays from pagination)
|
|
272
|
+
// Check all possible ID fields since items may have different ID structures
|
|
273
|
+
const index = items.findIndex((item: any) => {
|
|
274
|
+
if (!item) return false;
|
|
275
|
+
// Check all possible ID fields
|
|
276
|
+
const idsToCheck = [item.id, item._id, item.user_id].filter(
|
|
277
|
+
(v) => v !== undefined && v !== null,
|
|
278
|
+
);
|
|
279
|
+
return idsToCheck.some(
|
|
280
|
+
(itemId) => itemId === id || String(itemId) === String(id),
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
if (index === -1) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return this.removeItem(index);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Check if an item ID is pending removal
|
|
293
|
+
* @param id - The item ID to check
|
|
294
|
+
* @returns true if the item is pending removal
|
|
295
|
+
*/
|
|
296
|
+
isPendingRemoval(id: string | number): boolean {
|
|
297
|
+
return pendingRemovals.has(id);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get all pending removal IDs
|
|
302
|
+
* @returns Set of pending removal IDs
|
|
303
|
+
*/
|
|
304
|
+
getPendingRemovals(): Set<string | number> {
|
|
305
|
+
return new Set(pendingRemovals);
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Clear a specific pending removal
|
|
310
|
+
* @param id - The item ID to clear from pending removals
|
|
311
|
+
*/
|
|
312
|
+
clearPendingRemoval(id: string | number): void {
|
|
313
|
+
pendingRemovals.delete(id);
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Clear all pending removals
|
|
318
|
+
*/
|
|
319
|
+
clearAllPendingRemovals(): void {
|
|
320
|
+
pendingRemovals.clear();
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Filter items array to exclude pending removals
|
|
325
|
+
* Utility method for use in collection adapters
|
|
326
|
+
* @param items - Array of items to filter
|
|
327
|
+
* @returns Filtered array without pending removal items
|
|
328
|
+
*/
|
|
329
|
+
filterPendingRemovals<I extends { id?: any; _id?: any }>(
|
|
330
|
+
items: I[],
|
|
331
|
+
): I[] {
|
|
332
|
+
if (pendingRemovals.size === 0) return items;
|
|
333
|
+
|
|
334
|
+
return items.filter((item) => {
|
|
335
|
+
const id = item._id || item.id;
|
|
336
|
+
return !pendingRemovals.has(id);
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
|
|
72
340
|
// Loading operations
|
|
73
341
|
async loadRange(
|
|
74
342
|
page: number,
|
|
75
343
|
limit: number,
|
|
76
344
|
strategy: string = "page",
|
|
77
|
-
alignment?: string
|
|
345
|
+
alignment?: string,
|
|
78
346
|
) {
|
|
79
347
|
isLoading = true;
|
|
80
348
|
|
|
@@ -118,7 +386,7 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
118
386
|
if (nextOffset < totalItems) {
|
|
119
387
|
await component.viewport.collection.loadRange(
|
|
120
388
|
nextOffset,
|
|
121
|
-
config.pagination?.limit || 20
|
|
389
|
+
config.pagination?.limit || 20,
|
|
122
390
|
);
|
|
123
391
|
}
|
|
124
392
|
}
|
|
@@ -201,7 +469,7 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
201
469
|
scrollToIndex: (
|
|
202
470
|
index: number,
|
|
203
471
|
alignment: "start" | "center" | "end" = "start",
|
|
204
|
-
animate?: boolean
|
|
472
|
+
animate?: boolean,
|
|
205
473
|
) => {
|
|
206
474
|
if (component.viewport) {
|
|
207
475
|
component.viewport.scrollToIndex(index, alignment);
|
|
@@ -209,14 +477,50 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
209
477
|
return Promise.resolve();
|
|
210
478
|
},
|
|
211
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Scroll to index and select item after data loads
|
|
482
|
+
* Useful for runtime scroll+select when VList is already created
|
|
483
|
+
*/
|
|
484
|
+
scrollToIndexAndSelect: async function (
|
|
485
|
+
index: number,
|
|
486
|
+
selectId: string | number,
|
|
487
|
+
alignment: "start" | "center" | "end" = "start",
|
|
488
|
+
) {
|
|
489
|
+
if (component.viewport) {
|
|
490
|
+
component.viewport.scrollToIndex(index, alignment);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Listen for range load to complete, then select
|
|
494
|
+
const onRangeLoaded = () => {
|
|
495
|
+
component.off?.("viewport:range-loaded", onRangeLoaded);
|
|
496
|
+
requestAnimationFrame(() => {
|
|
497
|
+
if (component.selectById) {
|
|
498
|
+
component.selectById(selectId);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
component.on?.("viewport:range-loaded", onRangeLoaded);
|
|
504
|
+
|
|
505
|
+
// Fallback timeout in case event doesn't fire (data already loaded)
|
|
506
|
+
setTimeout(() => {
|
|
507
|
+
component.off?.("viewport:range-loaded", onRangeLoaded);
|
|
508
|
+
if (component.selectById) {
|
|
509
|
+
component.selectById(selectId);
|
|
510
|
+
}
|
|
511
|
+
}, 300);
|
|
512
|
+
|
|
513
|
+
return Promise.resolve();
|
|
514
|
+
},
|
|
515
|
+
|
|
212
516
|
scrollToItem: async function (
|
|
213
517
|
itemId: string,
|
|
214
518
|
alignment: "start" | "center" | "end" = "start",
|
|
215
|
-
animate?: boolean
|
|
519
|
+
animate?: boolean,
|
|
216
520
|
) {
|
|
217
521
|
const items = this.getItems();
|
|
218
522
|
const index = items.findIndex(
|
|
219
|
-
(item: any) => String(item.id) === String(itemId)
|
|
523
|
+
(item: any) => String(item.id) === String(itemId),
|
|
220
524
|
);
|
|
221
525
|
|
|
222
526
|
if (index >= 0) {
|
|
@@ -242,7 +546,7 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
242
546
|
scrollToPage: async function (
|
|
243
547
|
pageNum: number,
|
|
244
548
|
alignment: "start" | "center" | "end" = "start",
|
|
245
|
-
animate?: boolean
|
|
549
|
+
animate?: boolean,
|
|
246
550
|
) {
|
|
247
551
|
// console.log(`[VList] scrollToPage(${pageNum})`);
|
|
248
552
|
|
|
@@ -262,7 +566,7 @@ export const withAPI = <T extends VListItem = VListItem>(
|
|
|
262
566
|
`[VList] Cannot jump to page ${pageNum} in cursor mode. ` +
|
|
263
567
|
`Loading pages sequentially from ${
|
|
264
568
|
highestLoadedPage + 1
|
|
265
|
-
} to ${pageNum}
|
|
569
|
+
} to ${pageNum}`,
|
|
266
570
|
);
|
|
267
571
|
}
|
|
268
572
|
}
|