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.
@@ -7,6 +7,7 @@
7
7
 
8
8
  export { createVList } from "./vlist";
9
9
  export type {
10
+ RemoveItemOptions,
10
11
  VListConfig,
11
12
  VListComponent,
12
13
  VListItem,
@@ -3,6 +3,14 @@
3
3
  */
4
4
 
5
5
  import type { BaseComponent, ElementComponent } from "mtrl";
6
+
7
+ /** Options for removeItemById */
8
+ export interface RemoveItemOptions {
9
+ /** Track as pending removal to filter from future fetches (default: true) */
10
+ trackPending?: boolean;
11
+ /** Timeout in ms to clear pending removal (default: 5000) */
12
+ pendingTimeout?: number;
13
+ }
6
14
  // Collection types are not exposed by mtrl; define minimal interfaces locally
7
15
  export interface CollectionItem {
8
16
  id: string | number;
@@ -18,7 +26,7 @@ export interface CollectionConfig<T = any> {
18
26
  export interface Collection<T = any> {
19
27
  loadMissingRanges?: (
20
28
  range: { start: number; end: number },
21
- reason?: string
29
+ reason?: string,
22
30
  ) => Promise<void>;
23
31
  }
24
32
  import type { ViewportComponent } from "../../core/viewport/types";
@@ -82,7 +90,7 @@ export type ListTemplate = (item: any, index: number) => string | HTMLElement;
82
90
  */
83
91
  export type ListItemTemplate<T = any> = (
84
92
  item: T,
85
- index: number
93
+ index: number,
86
94
  ) => string | HTMLElement;
87
95
 
88
96
  /**
@@ -325,6 +333,19 @@ export interface ListEvents<T = any> {
325
333
  "render:complete": {
326
334
  renderRange: { start: number; end: number; count: number };
327
335
  };
336
+
337
+ /** Item updated */
338
+ "item:updated": {
339
+ item: T;
340
+ index: number;
341
+ previousItem: T;
342
+ wasVisible: boolean;
343
+ };
344
+
345
+ /** Viewport range loaded (data available for range) */
346
+ "viewport:range-loaded": {
347
+ range: { start: number; end: number };
348
+ };
328
349
  }
329
350
 
330
351
  /**
@@ -347,9 +368,80 @@ export interface ListAPI<T extends ListItem = ListItem> {
347
368
  /** Remove items by indices */
348
369
  removeItems(indices: number[]): void;
349
370
 
371
+ /**
372
+ * Remove item at a specific index
373
+ * Removes the item from the collection, updates totalItems, and triggers re-render
374
+ * @param index - The index of the item to remove
375
+ * @returns true if item was found and removed, false otherwise
376
+ */
377
+ removeItem(index: number): boolean;
378
+
379
+ /**
380
+ * Remove item by ID
381
+ * Finds the item in the collection by its ID and removes it
382
+ * Updates totalItems and triggers re-render of visible items
383
+ * Optionally tracks as pending removal to filter from future fetches
384
+ * @param id - The item ID to find and remove
385
+ * @param options - Remove options (trackPending, pendingTimeout)
386
+ * @returns true if item was found and removed, false otherwise
387
+ */
388
+ removeItemById(id: string | number, options?: RemoveItemOptions): boolean;
389
+
390
+ /**
391
+ * Check if an item ID is pending removal
392
+ * @param id - The item ID to check
393
+ * @returns true if the item is pending removal
394
+ */
395
+ isPendingRemoval(id: string | number): boolean;
396
+
397
+ /**
398
+ * Get all pending removal IDs
399
+ * @returns Set of pending removal IDs
400
+ */
401
+ getPendingRemovals(): Set<string | number>;
402
+
403
+ /**
404
+ * Clear a specific pending removal
405
+ * @param id - The item ID to clear from pending removals
406
+ */
407
+ clearPendingRemoval(id: string | number): void;
408
+
409
+ /**
410
+ * Clear all pending removals
411
+ */
412
+ clearAllPendingRemovals(): void;
413
+
414
+ /**
415
+ * Filter items array to exclude pending removals
416
+ * Utility method for use in collection adapters
417
+ * @param items - Array of items to filter
418
+ * @returns Filtered array without pending removal items
419
+ */
420
+ filterPendingRemovals<I extends { id?: any; _id?: any }>(items: I[]): I[];
421
+
350
422
  /** Update item at index */
351
423
  updateItem(index: number, item: T): void;
352
424
 
425
+ /**
426
+ * Update item by ID
427
+ * Finds the item in the collection by its ID and updates it with new data
428
+ * Re-renders the item if currently visible in the viewport
429
+ * @param id - The item ID to find
430
+ * @param data - Partial data to merge with existing item, or full item replacement
431
+ * @param options - Update options
432
+ * @returns true if item was found and updated, false otherwise
433
+ */
434
+ updateItemById(
435
+ id: string | number,
436
+ data: Partial<T>,
437
+ options?: {
438
+ /** If true, replace the entire item instead of merging (default: false) */
439
+ replace?: boolean;
440
+ /** If true, re-render even if not visible (default: false) */
441
+ forceRender?: boolean;
442
+ },
443
+ ): boolean;
444
+
353
445
  /** Get item at index */
354
446
  getItem(index: number): T | undefined;
355
447
 
@@ -363,7 +455,7 @@ export interface ListAPI<T extends ListItem = ListItem> {
363
455
  /** Scroll to item index */
364
456
  scrollToIndex(
365
457
  index: number,
366
- alignment?: "start" | "center" | "end"
458
+ alignment?: "start" | "center" | "end",
367
459
  ): Promise<void>;
368
460
 
369
461
  /** Scroll to top */
@@ -404,6 +496,32 @@ export interface ListAPI<T extends ListItem = ListItem> {
404
496
  /** Check if item is selected */
405
497
  isSelected(index: number): boolean;
406
498
 
499
+ /** Select an item by its ID
500
+ * @param id - The ID of the item to select
501
+ * @param silent - If true, selection won't emit change event (default: false)
502
+ */
503
+ selectById(id: string | number, silent?: boolean): boolean;
504
+
505
+ /**
506
+ * Select item at index, scrolling and waiting for data if needed
507
+ * Handles virtual scrolling by loading data before selecting
508
+ */
509
+ selectAtIndex(index: number): Promise<boolean>;
510
+
511
+ /**
512
+ * Select next item relative to current selection
513
+ * Handles virtual scrolling by loading data before selecting
514
+ * @returns Promise resolving to true if selection changed, false if at end
515
+ */
516
+ selectNext(): Promise<boolean>;
517
+
518
+ /**
519
+ * Select previous item relative to current selection
520
+ * Handles virtual scrolling by loading data before selecting
521
+ * @returns Promise resolving to true if selection changed, false if at start
522
+ */
523
+ selectPrevious(): Promise<boolean>;
524
+
407
525
  // State
408
526
  /** Get current list state */
409
527
  getState(): ListState;
@@ -442,7 +560,7 @@ export interface ListAPI<T extends ListItem = ListItem> {
442
560
 
443
561
  /** Set error template */
444
562
  setErrorTemplate(
445
- template: string | ((error: Error) => string | HTMLElement)
563
+ template: string | ((error: Error) => string | HTMLElement),
446
564
  ): void;
447
565
 
448
566
  // Configuration
@@ -482,15 +600,15 @@ export interface ListComponent<T extends ListItem = ListItem>
482
600
  /** Event system (inherited from BaseComponent) */
483
601
  on<K extends keyof ListEvents<T>>(
484
602
  event: K,
485
- handler: (payload: ListEvents<T>[K]) => void
603
+ handler: (payload: ListEvents<T>[K]) => void,
486
604
  ): void;
487
605
  emit<K extends keyof ListEvents<T>>(
488
606
  event: K,
489
- payload: ListEvents<T>[K]
607
+ payload: ListEvents<T>[K],
490
608
  ): void;
491
609
  off<K extends keyof ListEvents<T>>(
492
610
  event: K,
493
- handler?: (payload: ListEvents<T>[K]) => void
611
+ handler?: (payload: ListEvents<T>[K]) => void,
494
612
  ): void;
495
613
  }
496
614
 
@@ -532,13 +650,25 @@ export interface VListConfig<T extends ListItem = ListItem> {
532
650
  ariaLabel?: string;
533
651
  debug?: boolean;
534
652
 
653
+ // Initial scroll position (0-based index)
654
+ // When set, VList will start loading from this position instead of 0
655
+ initialScrollIndex?: number;
656
+
657
+ // ID of item to select after initial load completes
658
+ // Works with initialScrollIndex to scroll to position and then select the item
659
+ selectId?: string | number;
660
+
661
+ // Whether to automatically load data on initialization (default: true)
662
+ // Set to false to defer loading until manually triggered
663
+ autoLoad?: boolean;
664
+
535
665
  // Data source
536
666
  items?: T[];
537
667
 
538
668
  // Template for rendering items
539
669
  template?: (
540
670
  item: T,
541
- index: number
671
+ index: number,
542
672
  ) => string | HTMLElement | any[] | Record<string, any>;
543
673
 
544
674
  // Collection configuration
@@ -572,6 +702,8 @@ export interface VListConfig<T extends ListItem = ListItem> {
572
702
  bufferSize?: number;
573
703
  renderDebounce?: number;
574
704
  maxConcurrentRequests?: number;
705
+ /** Velocity threshold (px/ms) above which data loading is cancelled and placeholders are shown. Default: 2 */
706
+ cancelLoadThreshold?: number;
575
707
  };
576
708
 
577
709
  // Selection configuration
@@ -129,7 +129,7 @@ const PREFIX_WITH_DASH = `${PREFIX}-`;
129
129
  function getCachedClassName(
130
130
  type: string,
131
131
  property: string,
132
- value: string | number
132
+ value: string | number,
133
133
  ): string {
134
134
  const key = `${type}-${property}-${value}`;
135
135
  if (!classCache.has(key)) {
@@ -138,7 +138,7 @@ function getCachedClassName(
138
138
  key,
139
139
  property === ""
140
140
  ? `layout__item--${value}`
141
- : `layout__item--${property}-${value}`
141
+ : `layout__item--${property}-${value}`,
142
142
  );
143
143
  } else {
144
144
  // For layout classes, align uses layout--{type}-{value}
@@ -184,7 +184,7 @@ function releaseFragment(fragment: DocumentFragment): void {
184
184
  */
185
185
  function processClassNames(
186
186
  options: Record<string, any>,
187
- skipPrefix = false
187
+ skipPrefix = false,
188
188
  ): Record<string, any> {
189
189
  if (!options) return options;
190
190
 
@@ -231,7 +231,7 @@ function processClassNames(
231
231
  .split(/\s+/)
232
232
  .filter(Boolean)
233
233
  .map((cls) =>
234
- cls.startsWith(PREFIX_WITH_DASH) ? cls : PREFIX_WITH_DASH + cls
234
+ cls.startsWith(PREFIX_WITH_DASH) ? cls : PREFIX_WITH_DASH + cls,
235
235
  )
236
236
  .join(" ");
237
237
  }
@@ -271,7 +271,7 @@ interface ExtractedParameters {
271
271
  function extractParameters(
272
272
  schema: SchemaItem[],
273
273
  startIndex: number,
274
- defaultCreator: Function
274
+ defaultCreator: Function,
275
275
  ): ExtractedParameters {
276
276
  const items = schema.slice(startIndex, startIndex + 3);
277
277
  let creator, name, options;
@@ -379,7 +379,7 @@ function hasClass(element: HTMLElement, className: string): boolean {
379
379
  */
380
380
  function createComponentInstance(
381
381
  Component: any,
382
- options: Record<string, any> = {}
382
+ options: Record<string, any> = {},
383
383
  ): any {
384
384
  try {
385
385
  // Destructure special configs in one operation
@@ -453,7 +453,7 @@ function createComponentInstance(
453
453
  }
454
454
  } else {
455
455
  for (const [eventName, handler] of Object.entries(
456
- finalEventsConfig
456
+ finalEventsConfig,
457
457
  )) {
458
458
  if (typeof handler === "function") {
459
459
  element.addEventListener(eventName, handler);
@@ -481,7 +481,7 @@ function createComponentInstance(
481
481
  */
482
482
  function applyLayoutClasses(
483
483
  element: HTMLElement,
484
- layoutConfig: LayoutConfig
484
+ layoutConfig: LayoutConfig,
485
485
  ): void {
486
486
  if (!element || !layoutConfig) return;
487
487
 
@@ -497,21 +497,21 @@ function applyLayoutClasses(
497
497
  addClass(
498
498
  element,
499
499
  PREFIX_WITH_DASH +
500
- getCachedClassName(layoutType, "gap", layoutConfig.gap)
500
+ getCachedClassName(layoutType, "gap", layoutConfig.gap),
501
501
  );
502
502
  }
503
503
  if (layoutConfig.align) {
504
504
  addClass(
505
505
  element,
506
506
  PREFIX_WITH_DASH +
507
- getCachedClassName(layoutType, "align", layoutConfig.align)
507
+ getCachedClassName(layoutType, "align", layoutConfig.align),
508
508
  );
509
509
  }
510
510
  if (layoutConfig.justify) {
511
511
  addClass(
512
512
  element,
513
513
  PREFIX_WITH_DASH +
514
- getCachedClassName(layoutType, "justify", layoutConfig.justify)
514
+ getCachedClassName(layoutType, "justify", layoutConfig.justify),
515
515
  );
516
516
  }
517
517
  }
@@ -522,17 +522,17 @@ function applyLayoutClasses(
522
522
  addClass(
523
523
  element,
524
524
  PREFIX_WITH_DASH +
525
- getCachedClassName("grid", "cols", layoutConfig.columns)
525
+ getCachedClassName("grid", "cols", layoutConfig.columns),
526
526
  );
527
527
  } else if (layoutConfig.columns === "auto-fill") {
528
528
  addClass(
529
529
  element,
530
- PREFIX_WITH_DASH + getCachedClassName("grid", "fill", "auto")
530
+ PREFIX_WITH_DASH + getCachedClassName("grid", "fill", "auto"),
531
531
  );
532
532
  } else if (layoutConfig.columns === "auto-fit") {
533
533
  addClass(
534
534
  element,
535
- PREFIX_WITH_DASH + getCachedClassName("grid", "cols", "auto-fit")
535
+ PREFIX_WITH_DASH + getCachedClassName("grid", "cols", "auto-fit"),
536
536
  );
537
537
  }
538
538
  if (layoutConfig.dense)
@@ -569,7 +569,7 @@ function applyLayoutClasses(
569
569
  */
570
570
  function applyLayoutItemClasses(
571
571
  element: HTMLElement,
572
- itemConfig: LayoutItemConfig
572
+ itemConfig: LayoutItemConfig,
573
573
  ): void {
574
574
  if (!element || !itemConfig) return;
575
575
 
@@ -579,53 +579,53 @@ function applyLayoutItemClasses(
579
579
  if (itemConfig.width && itemConfig.width >= 1 && itemConfig.width <= 12) {
580
580
  addClass(
581
581
  element,
582
- PREFIX_WITH_DASH + getCachedClassName("item", "", itemConfig.width)
582
+ PREFIX_WITH_DASH + getCachedClassName("item", "", itemConfig.width),
583
583
  );
584
584
  }
585
585
  if (itemConfig.sm)
586
586
  addClass(
587
587
  element,
588
- PREFIX_WITH_DASH + getCachedClassName("item", "sm", itemConfig.sm)
588
+ PREFIX_WITH_DASH + getCachedClassName("item", "sm", itemConfig.sm),
589
589
  );
590
590
  if (itemConfig.md)
591
591
  addClass(
592
592
  element,
593
- PREFIX_WITH_DASH + getCachedClassName("item", "md", itemConfig.md)
593
+ PREFIX_WITH_DASH + getCachedClassName("item", "md", itemConfig.md),
594
594
  );
595
595
  if (itemConfig.lg)
596
596
  addClass(
597
597
  element,
598
- PREFIX_WITH_DASH + getCachedClassName("item", "lg", itemConfig.lg)
598
+ PREFIX_WITH_DASH + getCachedClassName("item", "lg", itemConfig.lg),
599
599
  );
600
600
  if (itemConfig.xl)
601
601
  addClass(
602
602
  element,
603
- PREFIX_WITH_DASH + getCachedClassName("item", "xl", itemConfig.xl)
603
+ PREFIX_WITH_DASH + getCachedClassName("item", "xl", itemConfig.xl),
604
604
  );
605
605
 
606
606
  // Grid span classes
607
607
  if (itemConfig.span)
608
608
  addClass(
609
609
  element,
610
- PREFIX_WITH_DASH + getCachedClassName("item", "span", itemConfig.span)
610
+ PREFIX_WITH_DASH + getCachedClassName("item", "span", itemConfig.span),
611
611
  );
612
612
  if (itemConfig.rowSpan)
613
613
  addClass(
614
614
  element,
615
615
  PREFIX_WITH_DASH +
616
- getCachedClassName("item", "row-span", itemConfig.rowSpan)
616
+ getCachedClassName("item", "row-span", itemConfig.rowSpan),
617
617
  );
618
618
 
619
619
  // Order and alignment
620
620
  if (itemConfig.order)
621
621
  addClass(
622
622
  element,
623
- PREFIX_WITH_DASH + getCachedClassName("item", "order", itemConfig.order)
623
+ PREFIX_WITH_DASH + getCachedClassName("item", "order", itemConfig.order),
624
624
  );
625
625
  if (itemConfig.align)
626
626
  addClass(
627
627
  element,
628
- PREFIX_WITH_DASH + getCachedClassName("item", "self", itemConfig.align)
628
+ PREFIX_WITH_DASH + getCachedClassName("item", "self", itemConfig.align),
629
629
  );
630
630
  if (itemConfig.auto)
631
631
  addClass(element, `${PREFIX_WITH_DASH}layout__item--auto`);
@@ -638,10 +638,10 @@ function getLayoutType(element: HTMLElement): string {
638
638
  return hasClass(element, `${PREFIX_WITH_DASH}layout--stack`)
639
639
  ? "stack"
640
640
  : hasClass(element, `${PREFIX_WITH_DASH}layout--row`)
641
- ? "row"
642
- : hasClass(element, `${PREFIX_WITH_DASH}layout--grid`)
643
- ? "grid"
644
- : "";
641
+ ? "row"
642
+ : hasClass(element, `${PREFIX_WITH_DASH}layout--grid`)
643
+ ? "grid"
644
+ : "";
645
645
  }
646
646
 
647
647
  // ============================================================================
@@ -656,7 +656,7 @@ function processArraySchema(
656
656
  schema: SchemaItem[] | any,
657
657
  parentElement: HTMLElement | null = null,
658
658
  level: number = 0,
659
- options: LayoutOptions = {}
659
+ options: LayoutOptions = {},
660
660
  ): LayoutResult {
661
661
  level++;
662
662
  const layout: Record<string, any> = {};
@@ -678,6 +678,11 @@ function processArraySchema(
678
678
  if (Array.isArray(item)) {
679
679
  const container = component || parentElement;
680
680
  const result = processArraySchema(item, container, level, options);
681
+ // Merge nested components array instead of overwriting
682
+ if (Array.isArray(result.layout.components)) {
683
+ components.push(...result.layout.components);
684
+ delete result.layout.components;
685
+ }
681
686
  Object.assign(layout, result.layout);
682
687
  continue;
683
688
  }
@@ -774,7 +779,7 @@ function processArraySchema(
774
779
  function processObjectSchema(
775
780
  schema: Record<string, any> | string,
776
781
  parentElement: HTMLElement | null = null,
777
- options: LayoutOptions = {}
782
+ options: LayoutOptions = {},
778
783
  ): LayoutResult {
779
784
  const layout: Record<string, any> = {};
780
785
  const defaultCreator = options.creator || createElement;
@@ -792,7 +797,7 @@ function processObjectSchema(
792
797
 
793
798
  const rootComponent = createComponentInstance(
794
799
  createElementFn,
795
- processedOptions
800
+ processedOptions,
796
801
  );
797
802
  layout.element = rootComponent;
798
803
  if (elementDef.name) layout[elementDef.name] = rootComponent;
@@ -805,7 +810,7 @@ function processObjectSchema(
805
810
  const childResult = processObjectSchema(
806
811
  elementDef.children,
807
812
  rootElement,
808
- options
813
+ options,
809
814
  );
810
815
  Object.assign(layout, childResult.layout);
811
816
  }
@@ -908,14 +913,47 @@ function createLayoutResult(layout: Record<string, any>): LayoutResult {
908
913
  },
909
914
 
910
915
  destroy(): void {
911
- // Call destroy on components
912
- for (const key in layout) {
913
- const component = layout[key];
914
- if (component && typeof component.destroy === "function") {
915
- component.destroy();
916
+ // Track destroyed components to avoid double-destroy
917
+ const destroyed = new Set<any>();
918
+
919
+ // Helper to safely destroy a component
920
+ const destroyComponent = (component: any): void => {
921
+ if (!component || destroyed.has(component)) return;
922
+
923
+ // Skip plain HTML elements and non-objects
924
+ if (component instanceof HTMLElement || typeof component !== "object")
925
+ return;
926
+
927
+ // Check if it's a component with destroy method
928
+ if (typeof component.destroy === "function") {
929
+ destroyed.add(component);
930
+ try {
931
+ component.destroy();
932
+ } catch (e) {
933
+ // Ignore destroy errors - component may already be cleaned up
934
+ }
935
+ }
936
+ };
937
+
938
+ // First destroy components from the components array (if present)
939
+ // This ensures all named components are destroyed even if nested
940
+ if (Array.isArray(layout.components)) {
941
+ for (const [name, component] of layout.components) {
942
+ destroyComponent(component);
916
943
  }
917
944
  }
918
945
 
946
+ // Then iterate over all layout keys for any missed components
947
+ for (const key in layout) {
948
+ if (key === "element" || key === "components") continue;
949
+ destroyComponent(layout[key]);
950
+ }
951
+
952
+ // Clear the components array
953
+ if (Array.isArray(layout.components)) {
954
+ layout.components.length = 0;
955
+ }
956
+
919
957
  // Remove root element from DOM
920
958
  if (layout.element) {
921
959
  const element = isComponent(layout.element)
@@ -925,6 +963,11 @@ function createLayoutResult(layout: Record<string, any>): LayoutResult {
925
963
  element.parentNode.removeChild(element);
926
964
  }
927
965
  }
966
+
967
+ // Clear references to help GC
968
+ for (const key in layout) {
969
+ delete layout[key];
970
+ }
928
971
  },
929
972
  };
930
973
  }
@@ -940,7 +983,7 @@ function createLayoutResult(layout: Record<string, any>): LayoutResult {
940
983
  export function createLayout(
941
984
  schema: any,
942
985
  parentElement: HTMLElement | null = null,
943
- options: LayoutOptions = {}
986
+ options: LayoutOptions = {},
944
987
  ): LayoutResult {
945
988
  // Handle function schemas
946
989
  if (typeof schema === "function") {
@@ -32,7 +32,7 @@ export const VIEWPORT_CONSTANTS = {
32
32
 
33
33
  // Loading settings
34
34
  LOADING: {
35
- CANCEL_THRESHOLD: 2, // px/ms - velocity above which loads cancel
35
+ CANCEL_THRESHOLD: 25, // px/ms - velocity above which loads cancel
36
36
  MAX_CONCURRENT_REQUESTS: 1, // Parallel requests allowed
37
37
  DEFAULT_RANGE_SIZE: 20, // Items per request
38
38
  DEBOUNCE_LOADING: 150, // Debounce delay (ms)
@@ -86,6 +86,11 @@ export const VIEWPORT_CONSTANTS = {
86
86
  LOADING_DELAY: 100, // ms - delay before showing loading state
87
87
  },
88
88
 
89
+ // Selection settings
90
+ SELECTION: {
91
+ SELECTED_CLASS: "viewport-item--selected",
92
+ },
93
+
89
94
  // Scrollbar settings (from list-manager)
90
95
  SCROLLBAR: {
91
96
  // CSS classes
@@ -131,7 +136,7 @@ export type ViewportConstants = typeof VIEWPORT_CONSTANTS;
131
136
  * Helper function to merge user constants with defaults
132
137
  */
133
138
  export function mergeConstants(
134
- userConstants: Partial<ViewportConstants> = {}
139
+ userConstants: Partial<ViewportConstants> = {},
135
140
  ): ViewportConstants {
136
141
  return {
137
142
  ...VIEWPORT_CONSTANTS,