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.
@@ -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
  }