All files / src/components VirtualFor.ts

100% Statements 65/65
100% Branches 15/15
100% Functions 5/5
100% Lines 65/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 1671x 1x 1x                                                                                                                                                                       1x 7x   7x 7x 7x 7x 7x     7x 7x 7x     7x 7x 7x     7x 7x 7x 7x 7x 7x 7x 7x 7x 7x     7x 3x 7x   7x   7x 7x 7x 7x 7x 7x   7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x   7x 7x 7x 7x 76x     76x 2x 2x 2x 2x   74x 76x 7x   7x 7x 7x   7x 7x  
import { h } from '../h';
import { For } from './For';
import { createVirtualizer } from './virtualizer';
import type { SignalGetter, Attribute } from '../types';
 
/**
 * Props for configuring the VirtualFor scroll container element.
 * Allows customization of the container's appearance and attributes.
 */
interface VirtualForContainerProps {
  /** CSS class name(s) for the scroll container */
  className?: string;
  /** Inline styles for the scroll container */
  style?: Record<string, string | SignalGetter<string>>;
  /** Custom attributes to apply to the container (e.g., data-testid, aria-label) */
  attributes?: Attribute[];
}
 
/**
 * Props for the VirtualFor component that renders large lists efficiently.
 */
interface VirtualForProps<T> {
  /** Signal containing the array of items to virtualize */
  each: SignalGetter<T[]>;
  /** Fixed height of each item in pixels */
  itemHeight: number;
  /** Function that renders each item, receiving the item data and its index */
  children: (item: T, index: number) => HTMLElement;
  /** Optional placeholder element to show while scrolling or for empty slots */
  placeholder?: HTMLElement;
  /** Number of extra items to render outside visible area (default: 5) */
  overscan?: number;
  /** Optional configuration for the scroll container element */
  containerProps?: VirtualForContainerProps;
}
 
/**
 * Renders large lists efficiently by only rendering visible items and a small buffer.
 *
 * VirtualFor uses a virtual scroller that calculates which items should be visible
 * based on scroll position and container height. Only visible items plus an overscan
 * buffer are rendered, making it suitable for lists with thousands of items.
 *
 * @param props Configuration object with list data, item height, and render function
 * @returns An HTMLElement containing the virtualized list with scroll container
 *
 * @example
 * ```typescript
 * const [items] = createSignal(Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` })));
 *
 * // Simple usage
 * const virtualList = VirtualFor({
 *   each: items,
 *   itemHeight: 50,
 *   children: (item, index) => h.div({}, `${index}: ${item.text}`)
 * });
 *
 * // With placeholder for loading states
 * const loadingPlaceholder = h.div({ 
 *   style: { height: '50px', background: '#f0f0f0' } 
 * }, 'Loading...');
 *
 * const listWithPlaceholder = VirtualFor({
 *   each: items,
 *   itemHeight: 50,
 *   placeholder: loadingPlaceholder,
 *   children: (item, index) => h.div({}, `${index}: ${item.text}`)
 * });
 *
 * // With container customization
 * const customList = VirtualFor({
 *   each: items,
 *   itemHeight: 50,
 *   overscan: 3,
 *   containerProps: {
 *     className: 'my-scroller',
 *     style: { height: '400px' },
 *     attributes: [
 *       { name: 'data-testid', value: 'virtual-list' },
 *       { name: 'aria-label', value: 'Virtualized item list' }
 *     ]
 *   },
 *   children: (item, index) => h.div({}, `${index}: ${item.text}`)
 * });
 * ```
 */
export function VirtualFor<T>(props: VirtualForProps<T>): HTMLElement {
  const { each, children, itemHeight, overscan, containerProps, placeholder } = props;
 
  const virtualizer = createVirtualizer({
    items: each,
    itemHeight,
    overscan,
  });
 
  // Extract container props with defaults
  const className = containerProps?.className;
  const customStyle = containerProps?.style || {};
  const customAttributes = containerProps?.attributes || [];
 
  // Filter out dangerous attributes that could break functionality
  const safeAttributes = customAttributes.filter(
    (attr) => !['ref', 'role', 'style', 'class', 'className'].includes(attr.name)
  );
 
  // Build container attributes object
  const containerAttrs: Record<string, unknown> = {
    ref: virtualizer.setContainer,
    class: className,
    role: 'list',
    style: {
      overflow: 'auto',
      height: '100%',
      ...customStyle,
    },
  };
 
  // Apply safe custom attributes
  safeAttributes.forEach((attr) => {
    containerAttrs[attr.name] = attr.value;
  });
 
  const container = h.div(containerAttrs);
 
  const sizer = h.div({
    style: {
      position: 'relative',
      height: () => `${virtualizer.totalHeight()}px`,
    },
  });
 
  const content = h.div({
    style: {
      position: 'absolute',
      top: '0',
      left: '0',
      width: '100%',
      willChange: 'transform',
      contain: 'layout',
      transform: () => `translate3d(0, ${virtualizer.visibleState().scrollOffset}px, 0)`,
    },
  });
 
  const renderedItems = For({
    each: virtualizer.visibleItems,
    key: (item) => item.index,
    children: (itemSignal) => {
      const it = itemSignal();
      
      // If placeholder is provided and data is not yet available, show placeholder
      if (placeholder && !it.data) {
        const placeholderClone = placeholder.cloneNode(true) as HTMLElement;
        placeholderClone.style.height = `${itemHeight}px`;
        return placeholderClone;
      }
      
      return children(it.data as T, it.index);
    },
  });
 
  content.appendChild(renderedItems);
  sizer.appendChild(content);
  container.appendChild(sizer);
 
  return container;
}