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.
@@ -13,101 +13,76 @@ interface SelectionState {
13
13
  lastSelectedIndex?: number;
14
14
  }
15
15
 
16
+ /**
17
+ * Get item ID from element or item data
18
+ */
19
+ const getItemId = (item: any): string | number | undefined => {
20
+ if (item?.id !== undefined) return item.id;
21
+ if (item?._id !== undefined) return item._id;
22
+ if (typeof item === "string" || typeof item === "number") return item;
23
+ return undefined;
24
+ };
25
+
16
26
  /**
17
27
  * Adds selection management capabilities to VList component
18
- * Works with viewport's virtual scrolling architecture
19
- *
20
- * @param config - VList configuration with selection options
21
- * @returns Function that enhances a component with selection management
22
28
  */
23
29
  export const withSelection = <T extends VListItem = VListItem>(
24
- config: VListConfig<T>
30
+ config: VListConfig<T>,
25
31
  ) => {
26
32
  return (component: VListComponent<T>): VListComponent<T> => {
27
- // Skip if selection is not enabled
28
33
  if (!config.selection?.enabled || config.selection?.mode === "none") {
29
- // console.log("🎯 [Selection] Skipped - not enabled or mode is none");
30
34
  return component;
31
35
  }
32
36
 
33
- // console.log("🎯 [Selection] Initializing selection feature", {
34
- // enabled: config.selection?.enabled,
35
- // mode: config.selection?.mode,
36
- // });
37
-
38
- // Initialize selection state
39
37
  const state: SelectionState = {
40
38
  selectedIds: new Set(),
41
39
  mode: config.selection?.mode || "single",
42
40
  lastSelectedIndex: undefined,
43
41
  };
44
42
 
45
- // Get configuration options
46
43
  const requireModifiers = config.selection?.requireModifiers ?? false;
47
44
 
48
- // Add BEM modifier class to container element
49
- const addContainerModifier = () => {
50
- if (component.element) {
51
- // Add selection mode modifier class following BEM convention
52
- addClass(component.element, `vlist--selection`);
53
- // Also add specific mode modifier
54
- addClass(component.element, `vlist--selection-${state.mode}`);
55
- }
56
- };
57
-
58
- // Defer initialization of pre-selected items until after setup
59
- const initializePreselectedItems = () => {
60
- if (config.selection?.selectedIndices && component.getItems) {
61
- config.selection.selectedIndices.forEach((index) => {
62
- const items = component.getItems();
63
- const item = items?.[index];
64
- if (item && (item as any).id !== undefined) {
65
- state.selectedIds.add((item as any).id);
66
- }
67
- });
68
- }
69
- };
70
-
71
45
  /**
72
- * Apply selection class to an element
46
+ * Apply selection class to rendered elements matching selected IDs
73
47
  */
74
- const applySelectionClass = (element: HTMLElement, isSelected: boolean) => {
75
- if (isSelected) {
76
- addClass(element, VLIST_CLASSES.SELECTED);
77
- } else {
78
- removeClass(element, VLIST_CLASSES.SELECTED);
79
- }
80
- };
48
+ const applySelectionToElements = () => {
49
+ const container = component.element?.querySelector(
50
+ `.${PREFIX}-viewport-items`,
51
+ );
52
+ if (!container) return;
81
53
 
82
- /**
83
- * Get item ID from element or item data
84
- */
85
- const getItemId = (item: any): string | number | undefined => {
86
- if (item?.id !== undefined) return item.id;
87
- if (typeof item === "string" || typeof item === "number") return item;
88
- return undefined;
54
+ const items = container.querySelectorAll(
55
+ `.${PREFIX}-viewport-item[data-id]`,
56
+ );
57
+
58
+ items.forEach((el) => {
59
+ const element = el as HTMLElement;
60
+ const id = element.dataset.id;
61
+ if (id !== undefined) {
62
+ // Check both string and number versions of the ID
63
+ const isSelected =
64
+ state.selectedIds.has(id) || state.selectedIds.has(Number(id));
65
+ if (isSelected) {
66
+ addClass(element, VLIST_CLASSES.SELECTED);
67
+ } else {
68
+ removeClass(element, VLIST_CLASSES.SELECTED);
69
+ }
70
+ }
71
+ });
89
72
  };
90
73
 
91
74
  /**
92
75
  * Handle item click for selection
93
76
  */
94
77
  const handleItemClick = (e: MouseEvent) => {
95
- // console.log("🎯 [Selection] Click detected on:", e.target);
96
-
97
- // Find the clicked viewport item element (wrapper)
98
78
  const viewportItem = (e.target as HTMLElement).closest(
99
- `.${PREFIX}-viewport-item[data-index]`
79
+ `.${PREFIX}-viewport-item[data-index]`,
100
80
  ) as HTMLElement;
101
- if (!viewportItem) {
102
- console.log("🎯 [Selection] No viewport item found for click");
103
- return;
104
- }
81
+ if (!viewportItem) return;
105
82
 
106
83
  const index = parseInt(viewportItem.dataset.index || "-1");
107
- // console.log(`🎯 [Selection] Clicked item index: ${index}`);
108
84
  if (index < 0) return;
109
85
 
110
- // Get the item data
111
86
  const enhancedComponent = component as any;
112
87
  const items = enhancedComponent.getItems?.();
113
88
  const item = items?.[index];
@@ -116,25 +91,10 @@ export const withSelection = <T extends VListItem = VListItem>(
116
91
  const itemId = getItemId(item);
117
92
  if (itemId === undefined) return;
118
93
 
119
- // Handle selection based on mode
120
94
  const wasSelected = state.selectedIds.has(itemId);
121
95
 
122
- // console.log("🎯 [Selection] Click detected:", {
123
- // index,
124
- // itemId,
125
- // wasSelected,
126
- // mode: state.mode,
127
- // shiftKey: e.shiftKey,
128
- // ctrlKey: e.ctrlKey,
129
- // metaKey: e.metaKey,
130
- // lastSelectedIndex: state.lastSelectedIndex,
131
- // });
132
-
133
96
  if (state.mode === "single") {
134
- // Clear previous selection
135
97
  state.selectedIds.clear();
136
-
137
- // Toggle selection
138
98
  if (!wasSelected) {
139
99
  state.selectedIds.add(itemId);
140
100
  state.lastSelectedIndex = index;
@@ -142,14 +102,11 @@ export const withSelection = <T extends VListItem = VListItem>(
142
102
  state.lastSelectedIndex = undefined;
143
103
  }
144
104
  } else if (state.mode === "multiple") {
145
- // Handle multi-select with keyboard modifiers
146
105
  if (e.shiftKey && state.lastSelectedIndex !== undefined) {
147
- // Range selection
148
106
  const start = Math.min(state.lastSelectedIndex, index);
149
107
  const end = Math.max(state.lastSelectedIndex, index);
150
108
 
151
109
  if (!e.ctrlKey && !e.metaKey) {
152
- // Clear existing selection if not holding ctrl/cmd
153
110
  state.selectedIds.clear();
154
111
  }
155
112
 
@@ -161,7 +118,6 @@ export const withSelection = <T extends VListItem = VListItem>(
161
118
  }
162
119
  }
163
120
  } else if (e.ctrlKey || e.metaKey) {
164
- // Toggle individual selection with Ctrl/Cmd
165
121
  if (wasSelected) {
166
122
  state.selectedIds.delete(itemId);
167
123
  } else {
@@ -169,19 +125,10 @@ export const withSelection = <T extends VListItem = VListItem>(
169
125
  }
170
126
  state.lastSelectedIndex = index;
171
127
  } else {
172
- // Single click without modifiers
173
- // console.log("🎯 [Selection] Single click without modifiers:", {
174
- // requireModifiers,
175
- // wasSelected,
176
- // willToggle: !requireModifiers,
177
- // });
178
-
179
128
  if (requireModifiers) {
180
- // If modifiers are required, single click selects only this item
181
129
  state.selectedIds.clear();
182
130
  state.selectedIds.add(itemId);
183
131
  } else {
184
- // If modifiers are NOT required, single click toggles selection
185
132
  if (wasSelected) {
186
133
  state.selectedIds.delete(itemId);
187
134
  } else {
@@ -192,216 +139,163 @@ export const withSelection = <T extends VListItem = VListItem>(
192
139
  }
193
140
  }
194
141
 
195
- // Update visible elements
196
- updateVisibleElements();
142
+ applySelectionToElements();
143
+ emitSelectionChange();
144
+ };
197
145
 
198
- // Emit selection change event
199
- const selectedItems =
200
- items?.filter((item: any) => {
201
- const id = getItemId(item);
202
- return id !== undefined && state.selectedIds.has(id);
203
- }) || [];
146
+ /**
147
+ * Emit selection change event
148
+ */
149
+ const emitSelectionChange = () => {
150
+ const enhancedComponent = component as any;
151
+ const items = enhancedComponent.getItems?.() || [];
204
152
 
205
- const selectedIndices =
206
- items?.reduce((acc: number[], item: any, idx: number) => {
153
+ const selectedItems = items.filter((item: any) => {
154
+ const id = getItemId(item);
155
+ return id !== undefined && state.selectedIds.has(id);
156
+ });
157
+
158
+ const selectedIndices = items.reduce(
159
+ (acc: number[], item: any, idx: number) => {
207
160
  const id = getItemId(item);
208
161
  if (id !== undefined && state.selectedIds.has(id)) {
209
162
  acc.push(idx);
210
163
  }
211
164
  return acc;
212
- }, [] as number[]) || [];
165
+ },
166
+ [] as number[],
167
+ );
213
168
 
214
- component.emit?.("selection:change", {
215
- selectedItems,
216
- selectedIndices,
217
- });
169
+ component.emit?.("selection:change", { selectedItems, selectedIndices });
218
170
 
219
- // Call the selection change callback if provided
220
171
  if (config.selection?.onSelectionChange) {
221
172
  config.selection.onSelectionChange(selectedItems, selectedIndices);
222
173
  }
223
-
224
- // Emit individual item selection event
225
- component.emit?.("item:selection:change", {
226
- item,
227
- index,
228
- isSelected: state.selectedIds.has(itemId),
229
- });
230
174
  };
231
175
 
232
- /**
233
- * Update selection state for visible elements
234
- */
235
- const updateVisibleElements = () => {
236
- const container = component.element?.querySelector(
237
- `.${PREFIX}-viewport-items`
238
- );
239
- if (!container) {
240
- // console.warn("🎯 [Selection] No viewport items container found");
241
- return;
176
+ // Setup: listen for renders and clicks
177
+ const setup = () => {
178
+ // Add mode class to container
179
+ if (component.element) {
180
+ addClass(component.element, "vlist--selection");
181
+ addClass(component.element, `vlist--selection-${state.mode}`);
182
+ component.element.addEventListener("click", handleItemClick, true);
242
183
  }
243
184
 
244
- const viewportItems = container.querySelectorAll(
245
- `.${PREFIX}-viewport-item[data-index]`
185
+ // Apply selection after each render
186
+ (component as any).on?.(
187
+ "viewport:items-rendered",
188
+ applySelectionToElements,
189
+ );
190
+ (component as any).on?.("viewport:rendered", applySelectionToElements);
191
+
192
+ // Clean up selection when item is removed
193
+ (component as any).on?.(
194
+ "item:removed",
195
+ (data: { item: any; index: number }) => {
196
+ const itemId = getItemId(data.item);
197
+ if (itemId !== undefined && state.selectedIds.has(itemId)) {
198
+ state.selectedIds.delete(itemId);
199
+ // If the removed item was the last selected index, clear it
200
+ if (state.lastSelectedIndex === data.index) {
201
+ state.lastSelectedIndex = undefined;
202
+ } else if (
203
+ state.lastSelectedIndex !== undefined &&
204
+ state.lastSelectedIndex > data.index
205
+ ) {
206
+ // Adjust lastSelectedIndex since items shifted down
207
+ state.lastSelectedIndex--;
208
+ }
209
+ emitSelectionChange();
210
+ }
211
+ },
246
212
  );
247
- // console.log(
248
- // `🎯 [Selection] Updating ${viewportItems.length} visible elements`
249
- // );
250
-
251
- const enhancedComponent = component as any;
252
- const items = enhancedComponent.getItems?.();
253
-
254
- viewportItems.forEach((viewportItem) => {
255
- const index = parseInt(
256
- (viewportItem as HTMLElement).dataset.index || "-1"
257
- );
258
- if (index < 0) return;
259
-
260
- const item = items?.[index];
261
- if (!item) return;
262
-
263
- const itemId = getItemId(item);
264
- if (itemId === undefined) return;
265
-
266
- const isSelected = state.selectedIds.has(itemId);
267
-
268
- // Apply selection class to the viewport item itself
269
- // The new layout system doesn't have a separate inner item
270
- applySelectionClass(viewportItem as HTMLElement, isSelected);
271
- });
272
213
  };
273
214
 
274
- // Setup listeners after component is fully initialized
275
- // Since selection is applied last, we can just use a timeout
276
- setTimeout(() => {
277
- // console.log("🎯 [Selection] Setting up listeners after initialization");
278
-
279
- // Add BEM modifier classes to the container
280
- addContainerModifier();
281
-
282
- setupSelectionListeners();
283
- }, 0);
284
-
285
- function setupSelectionListeners() {
286
- // Wait for viewport to be ready
287
- setTimeout(() => {
288
- // Initialize pre-selected items now that component is ready
289
- initializePreselectedItems();
290
-
291
- // Listen for render complete to update selection state
292
- // Using type assertion since viewport:rendered is not in ListEvents type
293
- (component as any).on?.("viewport:rendered", () => {
294
- updateVisibleElements();
295
- });
296
-
297
- // Add click listener to the viewport element
298
- if (component.element) {
299
- // Use capture phase to ensure we get the event
300
- component.element.addEventListener("click", handleItemClick, true);
301
- // console.log(
302
- // "🎯 [Selection] Click handler attached to element (capture phase)"
303
- // );
304
-
305
- // Test if handler works
306
- // setTimeout(() => {
307
- // const testItem = component.element?.querySelector(
308
- // `.${PREFIX}-viewport-item`
309
- // );
310
- // // console.log("🎯 [Selection] Test item found:", !!testItem);
311
- // }, 500);
215
+ // Initialize after component is ready
216
+ setTimeout(setup, 0);
217
+
218
+ // Listen for initial load complete to auto-select item
219
+ // This must be in withSelection because selectById is defined here
220
+ (component as any).on?.(
221
+ "collection:initial-load-complete",
222
+ (data: { selectId: string | number }) => {
223
+ if (data?.selectId !== undefined) {
224
+ // Use requestAnimationFrame to ensure rendering is complete
225
+ requestAnimationFrame(() => {
226
+ if (state.mode === "single") {
227
+ state.selectedIds.clear();
228
+ }
229
+ state.selectedIds.add(data.selectId);
230
+ applySelectionToElements();
231
+ emitSelectionChange();
232
+ });
312
233
  }
313
- }, 100);
314
- }
234
+ },
235
+ );
315
236
 
316
- // Clean up on destroy
237
+ // Cleanup on destroy
317
238
  const originalDestroy = component.destroy;
318
239
  component.destroy = () => {
319
- if (component.element) {
320
- component.element.removeEventListener("click", handleItemClick, true);
321
- }
240
+ component.element?.removeEventListener("click", handleItemClick, true);
322
241
  originalDestroy?.();
323
242
  };
324
243
 
325
- // Create the enhanced component
326
- const enhancedComponent = {
244
+ // Enhanced component with selection API
245
+ return {
327
246
  ...component,
328
247
 
329
- // Selection API methods
330
248
  selectItems(indices: number[]) {
331
- // Use type assertion to access getItems which is added by API feature
332
- const getItemsFn = (this as any).getItems;
333
- if (!getItemsFn) {
334
- console.warn("🎯 [Selection] getItems not available yet");
335
- return;
336
- }
337
- const items = getItemsFn();
338
-
339
- if (state.mode === "single" && indices.length > 1) {
340
- // In single mode, only select the first item
341
- indices = [indices[0]];
249
+ const items = (this as any).getItems?.();
250
+ if (!items) return;
251
+
252
+ if (state.mode === "single") {
253
+ // In single mode, clear previous selection before adding new one
254
+ state.selectedIds.clear();
255
+ if (indices.length > 1) {
256
+ indices = [indices[0]];
257
+ }
342
258
  }
343
259
 
344
260
  indices.forEach((index) => {
345
- const item = items?.[index];
346
- const itemId = getItemId(item);
261
+ const itemId = getItemId(items[index]);
347
262
  if (itemId !== undefined) {
348
263
  state.selectedIds.add(itemId);
349
264
  }
350
265
  });
351
266
 
352
- // Update lastSelectedIndex for shift+click range selection
353
267
  if (indices.length > 0) {
354
268
  state.lastSelectedIndex = indices[indices.length - 1];
355
269
  }
356
270
 
357
- updateVisibleElements();
358
- (this as any).emit?.("selection:change", {
359
- selectedItems: (this as any).getSelectedItems(),
360
- selectedIndices: (this as any).getSelectedIndices(),
361
- });
271
+ applySelectionToElements();
272
+ emitSelectionChange();
362
273
  },
363
274
 
364
275
  deselectItems(indices: number[]) {
365
- // Use type assertion to access getItems which is added by API feature
366
- const getItemsFn = (this as any).getItems;
367
- if (!getItemsFn) {
368
- console.warn("🎯 [Selection] getItems not available yet");
369
- return;
370
- }
371
- const items = getItemsFn();
276
+ const items = (this as any).getItems?.();
277
+ if (!items) return;
372
278
 
373
279
  indices.forEach((index) => {
374
- const item = items?.[index];
375
- const itemId = getItemId(item);
280
+ const itemId = getItemId(items[index]);
376
281
  if (itemId !== undefined) {
377
282
  state.selectedIds.delete(itemId);
378
283
  }
379
284
  });
380
285
 
381
- updateVisibleElements();
382
- (this as any).emit?.("selection:change", {
383
- selectedItems: (this as any).getSelectedItems(),
384
- selectedIndices: (this as any).getSelectedIndices(),
385
- });
286
+ applySelectionToElements();
287
+ emitSelectionChange();
386
288
  },
387
289
 
388
290
  clearSelection() {
389
291
  state.selectedIds.clear();
390
292
  state.lastSelectedIndex = undefined;
391
- updateVisibleElements();
392
- (this as any).emit?.("selection:change", {
393
- selectedItems: [],
394
- selectedIndices: [],
395
- });
293
+ applySelectionToElements();
294
+ emitSelectionChange();
396
295
  },
397
296
 
398
297
  getSelectedItems(): T[] {
399
- // Use type assertion to access getItems which is added by API feature
400
- const getItemsFn = (this as any).getItems;
401
- if (!getItemsFn) {
402
- return [];
403
- }
404
- const items = getItemsFn() || [];
298
+ const items = (this as any).getItems?.() || [];
405
299
  return items.filter((item: any) => {
406
300
  const id = getItemId(item);
407
301
  return id !== undefined && state.selectedIds.has(id);
@@ -409,12 +303,7 @@ export const withSelection = <T extends VListItem = VListItem>(
409
303
  },
410
304
 
411
305
  getSelectedIndices(): number[] {
412
- // Use type assertion to access getItems which is added by API feature
413
- const getItemsFn = (this as any).getItems;
414
- if (!getItemsFn) {
415
- return [];
416
- }
417
- const items = getItemsFn() || [];
306
+ const items = (this as any).getItems?.() || [];
418
307
  return items.reduce((acc: number[], item: any, index: number) => {
419
308
  const id = getItemId(item);
420
309
  if (id !== undefined && state.selectedIds.has(id)) {
@@ -425,19 +314,122 @@ export const withSelection = <T extends VListItem = VListItem>(
425
314
  },
426
315
 
427
316
  isSelected(index: number): boolean {
428
- // Use type assertion to access getItems which is added by API feature
429
- const getItemsFn = (this as any).getItems;
430
- if (!getItemsFn) {
317
+ const items = (this as any).getItems?.();
318
+ const itemId = getItemId(items?.[index]);
319
+ return itemId !== undefined && state.selectedIds.has(itemId);
320
+ },
321
+
322
+ selectById(id: string | number, silent: boolean = false): boolean {
323
+ if (id === undefined || id === null) return false;
324
+
325
+ if (state.mode === "single") {
326
+ state.selectedIds.clear();
327
+ }
328
+
329
+ state.selectedIds.add(id);
330
+ applySelectionToElements();
331
+ if (!silent) {
332
+ emitSelectionChange();
333
+ }
334
+ return true;
335
+ },
336
+
337
+ /**
338
+ * Select item at index, scrolling and waiting for data if needed
339
+ * Handles virtual scrolling by loading data before selecting
340
+ */
341
+ async selectAtIndex(index: number): Promise<boolean> {
342
+ const enhancedComponent = component as any;
343
+ const totalItems = enhancedComponent.getItemCount?.() || 0;
344
+
345
+ if (index < 0 || index >= totalItems) {
431
346
  return false;
432
347
  }
433
- const items = getItemsFn();
434
- const item = items?.[index];
435
- const itemId = getItemId(item);
436
- return itemId !== undefined && state.selectedIds.has(itemId);
348
+
349
+ // First scroll to the index (triggers data loading if needed)
350
+ if (enhancedComponent.viewport?.scrollToIndex) {
351
+ enhancedComponent.viewport.scrollToIndex(index);
352
+ }
353
+
354
+ // Try to select immediately - works if item is already loaded
355
+ const items = enhancedComponent.getItems?.() || [];
356
+ if (items[index]) {
357
+ this.selectItems([index]);
358
+ return true;
359
+ }
360
+
361
+ // Item not loaded yet - wait for data to load then select
362
+ return new Promise<boolean>((resolve) => {
363
+ let resolved = false;
364
+
365
+ const onRangeLoaded = () => {
366
+ if (resolved) return;
367
+ resolved = true;
368
+ component.off?.("viewport:range-loaded", onRangeLoaded);
369
+ requestAnimationFrame(() => {
370
+ this.selectItems([index]);
371
+ resolve(true);
372
+ });
373
+ };
374
+
375
+ component.on?.("viewport:range-loaded", onRangeLoaded);
376
+
377
+ // Fallback timeout in case event doesn't fire (data already loaded)
378
+ setTimeout(() => {
379
+ if (resolved) return;
380
+ resolved = true;
381
+ component.off?.("viewport:range-loaded", onRangeLoaded);
382
+ this.selectItems([index]);
383
+ resolve(true);
384
+ }, 300);
385
+ });
437
386
  },
438
- };
439
387
 
440
- return enhancedComponent;
388
+ /**
389
+ * Select next item relative to current selection
390
+ * Handles virtual scrolling by loading data before selecting
391
+ */
392
+ async selectNext(): Promise<boolean> {
393
+ const enhancedComponent = component as any;
394
+ const selectedIndices = this.getSelectedIndices();
395
+
396
+ if (selectedIndices.length === 0) {
397
+ // Select first item
398
+ return this.selectAtIndex(0);
399
+ }
400
+
401
+ const currentIndex = selectedIndices[0];
402
+ const nextIndex = currentIndex + 1;
403
+ const totalItems = enhancedComponent.getItemCount?.() || 0;
404
+
405
+ if (nextIndex < totalItems) {
406
+ return this.selectAtIndex(nextIndex);
407
+ }
408
+
409
+ return false;
410
+ },
411
+
412
+ /**
413
+ * Select previous item relative to current selection
414
+ * Handles virtual scrolling by loading data before selecting
415
+ */
416
+ async selectPrevious(): Promise<boolean> {
417
+ const selectedIndices = this.getSelectedIndices();
418
+
419
+ if (selectedIndices.length === 0) {
420
+ return false;
421
+ }
422
+
423
+ const currentIndex = selectedIndices[0];
424
+ const prevIndex = currentIndex - 1;
425
+
426
+ if (prevIndex >= 0) {
427
+ return this.selectAtIndex(prevIndex);
428
+ }
429
+
430
+ return false;
431
+ },
432
+ };
441
433
  };
442
434
  };
443
435
 
@@ -12,15 +12,9 @@ import { createViewport } from "../../../core/viewport";
12
12
  * Adds viewport functionality to VList
13
13
  */
14
14
  export const withViewport = <T extends VListItem = VListItem>(
15
- config: VListConfig<T>
15
+ config: VListConfig<T>,
16
16
  ) => {
17
17
  return (component: any) => {
18
- // console.log("📋 [VList] Applying viewport feature", {
19
- // hasElement: !!component.element,
20
- // hasItems: !!config.items,
21
- // itemCount: config.items?.length || 0,
22
- // });
23
-
24
18
  // Set initial items if provided
25
19
  if (config.items) {
26
20
  component.items = config.items;