/ Examples
tableselection

Data Table

Virtualized data table combining withTable + withAsync, backed by a real SQLite database with 33,352 cities from 210 countries. Data loads lazily in chunks as you scroll — sorting, filtering, and search all happen server-side via /api/cities.

World Cities

0% 0.00 / 0.00 px/ms 0 / 0 rows
0 results 0 loaded 0 reqs 0 cols none
Source
// Data Table — Virtualized table with server-side sorting, filtering, and search
// Demonstrates withTable + withAsync backed by a real SQLite database (33K cities).
// All sorting and filtering happens server-side via /api/cities.
// Data loads lazily in chunks as the user scrolls — not all at once.

import { vlist, withTable, withSelection, withAsync } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import { initControls } from "./controls.js";

// =============================================================================
// Constants
// =============================================================================

export const ROW_HEIGHT = 36;
export const HEADER_HEIGHT = 36;
export const CHUNK_SIZE = 100;
const API_BASE =
  typeof location !== "undefined" ? location.origin : "http://localhost:3338";

// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================

export let list = null;
export let totalCities = 0;
export let currentRowHeight = ROW_HEIGHT;
export let currentPreset = "full";
export let currentBorderMode = "both";
export let sortKey = "population";
export let sortDirection = "desc";
export let searchQuery = "";
export let filterContinent = "";
export let loadRequests = 0;
export let loadedCount = 0;

export function setCurrentRowHeight(v) {
  currentRowHeight = v;
}
export function setCurrentPreset(v) {
  currentPreset = v;
}
export function setCurrentBorderMode(v) {
  currentBorderMode = v;
}
export function setSearchQuery(v) {
  searchQuery = v;
}
export function setFilterContinent(v) {
  filterContinent = v;
}

// =============================================================================
// Adapter — fetches cities from the SQLite-backed API
// =============================================================================

/**
 * Build query params from the current filter/sort state.
 * Called by the adapter on every read so sorting/filtering
 * is always handled server-side.
 */
function buildParams(offset, limit) {
  const params = new URLSearchParams({
    offset: String(offset),
    limit: String(limit),
    sort: sortKey || "population",
    direction: sortDirection || "desc",
  });

  if (searchQuery) params.set("search", searchQuery);
  if (filterContinent) params.set("continent", filterContinent);

  return params;
}

const citiesAdapter = {
  read: async ({ offset, limit }) => {
    loadRequests++;
    updateContext();

    const params = buildParams(offset, limit);
    const res = await fetch(`${API_BASE}/api/cities?${params}`);
    if (!res.ok) throw new Error(`API error: ${res.status}`);
    const data = await res.json();

    totalCities = data.total;
    loadedCount += data.items.length;
    updateContext();

    return {
      items: data.items,
      total: data.total,
      hasMore: data.hasMore,
    };
  },
};

// =============================================================================
// Column Presets
// =============================================================================

/** Population cell — formatted with locale separators */
const populationCell = (item) => {
  const pop = item.population;
  if (pop == null) return "";
  if (pop >= 1_000_000) {
    return `<span class="table-pop table-pop--mega">${(pop / 1_000_000).toFixed(1)}M</span>`;
  }
  if (pop >= 100_000) {
    return `<span class="table-pop table-pop--large">${(pop / 1_000).toFixed(0)}K</span>`;
  }
  return `<span class="table-pop">${pop.toLocaleString()}</span>`;
};

/** Continent badge */
const CONTINENT_COLORS = {
  Africa: "#f4511e",
  Americas: "#7cb342",
  Asia: "#e53935",
  Europe: "#1e88e5",
  Oceania: "#00acc1",
  "Indian Ocean": "#8e24aa",
  Antarctica: "#546e7a",
};

const continentCell = (item) => {
  const name = item.continent;
  if (!name) return "";
  const color = CONTINENT_COLORS[name] || "#757575";
  return `<span class="ui-badge ui-badge--pill" style="background:${color};color:#fff">${name}</span>`;
};

/** City name cell with country code badge */
const nameCell = (item) => {
  const cc = item.country_code || "";
  const name = item.name || "";
  return `
    <div class="table-name">
      <span class="table-cc">${cc}</span>
      <span class="table-name__text">${name}</span>
    </div>
  `;
};

const COLUMN_PRESETS = {
  default: [
    {
      key: "name",
      label: "City",
      width: 220,
      minWidth: 140,
      sortable: true,
      cell: nameCell,
    },
    {
      key: "country_code",
      label: "Country",
      width: 100,
      minWidth: 70,
      align: "center",
      sortable: true,
    },
    {
      key: "population",
      label: "Population",
      width: 140,
      minWidth: 100,
      align: "right",
      sortable: true,
      cell: populationCell,
    },
    {
      key: "continent",
      label: "Continent",
      width: 130,
      minWidth: 100,
      sortable: true,
      cell: continentCell,
    },
  ],

  compact: [
    {
      key: "name",
      label: "City",
      width: 200,
      minWidth: 120,
      sortable: true,
      cell: nameCell,
    },
    {
      key: "population",
      label: "Pop.",
      width: 100,
      minWidth: 80,
      align: "right",
      sortable: true,
      cell: populationCell,
    },
    {
      key: "continent",
      label: "Continent",
      width: 120,
      minWidth: 80,
      sortable: true,
    },
  ],

  full: [
    {
      key: "id",
      label: "#",
      width: 60,
      minWidth: 50,
      maxWidth: 80,
      resizable: false,
      align: "right",
      sortable: true,
    },
    {
      key: "name",
      label: "City",
      width: 200,
      minWidth: 140,
      sortable: true,
      cell: nameCell,
    },
    {
      key: "country_code",
      label: "Country",
      width: 100,
      minWidth: 70,
      align: "center",
      sortable: true,
    },
    {
      key: "population",
      label: "Population",
      width: 136,
      minWidth: 100,
      align: "right",
      sortable: true,
      cell: populationCell,
    },
    {
      key: "continent",
      label: "Continent",
      width: 130,
      minWidth: 100,
      sortable: true,
      cell: continentCell,
    },
    {
      key: "lat",
      label: "Lat",
      width: 70,
      minWidth: 70,
      align: "right",
      sortable: true,
    },
    {
      key: "lng",
      label: "Lng",
      width: 70,
      minWidth: 70,
      align: "right",
      sortable: true,
    },
  ],
};

export function getColumns() {
  return COLUMN_PRESETS[currentPreset] || COLUMN_PRESETS.default;
}

// =============================================================================
// Sorting — server-side via reload
// =============================================================================

export async function applySort(key, direction) {
  sortKey = key;
  sortDirection = direction || "asc";

  if (key === null) {
    sortKey = "id";
    sortDirection = "asc";
  }

  loadedCount = 0;
  if (list) {
    await list.reload();
  }

  updateContext();
  updateSortDetail();
}

// =============================================================================
// Apply filters — triggers reload with new server-side params
// =============================================================================

let filterDebounce = null;

export async function applyFilters() {
  clearTimeout(filterDebounce);
  filterDebounce = setTimeout(async () => {
    loadedCount = 0;
    if (list) {
      await list.reload();
    }
    updateContext();
    updateSortDetail();
  }, 150);
}

// =============================================================================
// Templates (fallback — cell templates are defined per column above)
// =============================================================================

const fallbackTemplate = () => "";

// =============================================================================
// Stats — shared info bar (progress, velocity, visible/total)
// =============================================================================

export const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => totalCities,
  getItemSize: () => currentRowHeight,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create / Recreate list
// =============================================================================

let firstVisibleIndex = 0;

export function createList() {
  if (list) {
    list.destroy();
    list = null;
  }

  const container = document.getElementById("list-container");
  container.innerHTML = "";

  // Reset load stats on recreate
  loadRequests = 0;
  loadedCount = 0;

  const columns = getColumns();
  const isStriped = currentBorderMode === "striped";
  const columnBorders = currentBorderMode === "both";
  const rowBorders = !isStriped && currentBorderMode !== "none";

  const builder = vlist({
    container: "#list-container",
    ariaLabel: "World cities data table",
    item: {
      height: currentRowHeight,
      template: fallbackTemplate,
      striped: isStriped,
    },
  });

  // Async adapter — lazy chunk-based loading from /api/cities
  builder.use(
    withAsync({
      adapter: citiesAdapter,
      autoLoad: true,
      storage: {
        chunkSize: CHUNK_SIZE,
        maxCachedItems: 10000,
      },
      loading: {
        cancelThreshold: 8,
        preloadThreshold: 2,
        preloadAhead: 50,
      },
    }),
  );

  builder.use(
    withTable({
      columns,
      rowHeight: currentRowHeight,
      headerHeight: HEADER_HEIGHT,
      resizable: true,
      columnBorders,
      rowBorders,
      minColumnWidth: 50,
      sort: sortKey ? { key: sortKey, direction: sortDirection } : undefined,
    }),
  );

  builder.use(withSelection({ mode: "single" }));

  list = builder.build();

  // Wire events
  list.on("scroll", updateInfo);
  list.on("range:change", ({ range }) => {
    firstVisibleIndex = range.start;
    updateInfo();
  });
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });

  // Sort event — server-side sorting via reload
  list.on("column:sort", async ({ key, direction }) => {
    await applySort(direction === null ? null : key, direction);

    // Update the visual indicator on the header
    if (list && list.setSort) {
      list.setSort(sortKey, sortDirection);
    }
  });

  // Selection event — show detail panel
  list.on("selection:change", ({ selected, items }) => {
    if (items.length > 0) {
      showCityDetail(items[0]);
    } else {
      clearCityDetail();
    }
  });

  // Track loaded items
  list.on("load:end", ({ items, total }) => {
    totalCities = total;
    updateInfo();
    updateContext();
  });

  // Restore scroll position if recreating (e.g. after column preset change)
  if (firstVisibleIndex > 0) {
    list.scrollToIndex(firstVisibleIndex, "start");
  }

  updateInfo();
  updateContext();
}

// =============================================================================
// City detail (panel) — shows selected city
// =============================================================================

const detailEl = document.getElementById("row-detail");

function showCityDetail(city) {
  if (!detailEl) return;

  // Guard against placeholder items
  if (!city || !city.name || String(city.id).startsWith("__placeholder")) {
    return;
  }

  const popStr = city.population.toLocaleString();
  const lat = city.lat >= 0 ? `${city.lat}°N` : `${Math.abs(city.lat)}°S`;
  const lng = city.lng >= 0 ? `${city.lng}°E` : `${Math.abs(city.lng)}°W`;
  const color = CONTINENT_COLORS[city.continent] || "#757575";

  detailEl.innerHTML = `
    <div class="ui-detail__header">
      <div class="table-detail__cc">${city.country_code}</div>
      <div>
        <div class="ui-detail__name">${city.name}</div>
        <div class="table-detail__role">${city.continent}</div>
      </div>
    </div>
  `;
}

function clearCityDetail() {
  if (!detailEl) return;
  detailEl.innerHTML = `
    <span class="ui-detail__empty">Click a row to see details</span>
  `;
}

// =============================================================================
// Sort detail (panel) — shows current sort state
// =============================================================================

const sortDetailEl = document.getElementById("sort-detail");

function updateSortDetail() {
  if (!sortDetailEl) return;

  if (sortKey === null) {
    sortDetailEl.innerHTML = `
      <span class="ui-detail__empty">Click a column header to sort</span>
    `;
  } else {
    const arrow = sortDirection === "asc" ? "▲" : "▼";
    const label = sortDirection === "asc" ? "Ascending" : "Descending";
    sortDetailEl.innerHTML = `
      <div class="sort-info">
        <span class="sort-info__key">${sortKey}</span>
        <span class="sort-info__dir">${arrow} ${label}</span>
      </div>
    `;
  }
}

// =============================================================================
// Info bar — right side (contextual)
// =============================================================================

const infoColumns = document.getElementById("info-columns");
const infoSort = document.getElementById("info-sort");

export function updateContext() {
  if (infoColumns) infoColumns.textContent = getColumns().length;
  if (infoSort) {
    infoSort.textContent =
      sortKey !== null ? `${sortKey} ${sortDirection}` : "none";
  }

  // Update result count
  const infoResults = document.getElementById("info-results");
  if (infoResults) {
    infoResults.textContent = totalCities.toLocaleString();
  }

  // Update loaded count
  const infoLoaded = document.getElementById("info-loaded");
  if (infoLoaded) {
    infoLoaded.textContent = loadedCount.toLocaleString();
  }

  // Update request count
  const infoRequests = document.getElementById("info-requests");
  if (infoRequests) {
    infoRequests.textContent = loadRequests;
  }
}

// =============================================================================
// Initialise
// =============================================================================

initControls();
createList();
/* Data Table — example styles */

/* ============================================================================
   vlist item overrides
   ============================================================================ */
#list-container {
    height: 600px;
}

#list-container .vlist-item {
    padding: 0;
}

#list-container .vlist {
    border-radius: var(--vlist-border-radius, 0.5rem);
}
#list-container .vlist-table-header-sort {
    font-size: 1.1em;
}

/* ============================================================================
   Table Header overrides
   ============================================================================ */

#list-container .vlist-table-header {
    text-transform: uppercase;
    font-size: 0.6875rem;
    letter-spacing: 0.04em;
    background: var(--bg-card) !important;
}

#list-container .vlist-table-header-cell {
    padding-left: 0.75rem;
    padding-right: 0.75rem;
}

/* ============================================================================
   Table Row & Cell
   ============================================================================ */

#list-container .vlist-table-row {
    font-size: 0.8125rem;
}

#list-container .vlist-table-cell {
    padding-left: 0.75rem;
    padding-right: 0.75rem;
    line-height: 1.4;
}

/* ============================================================================
   City Name Cell — country code badge + name inline
   ============================================================================ */

.table-name {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
}

.table-cc {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 20px;
    border-radius: 3px;
    color: var(--text-muted, #6b7280);
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.04em;
    flex-shrink: 0;
    border: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.06));
}

.table-name__text {
    font-weight: 500;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
    color: var(--vlist-text, #111827);
}

/* ============================================================================
   Population Cell
   ============================================================================ */

.table-pop {
    font-variant-numeric: tabular-nums;
    font-size: 0.8125rem;
    color: var(--text-muted, #6b7280);
}

.table-pop--mega {
    font-weight: 600;
    color: var(--vlist-text, #111827);
}

.table-pop--large {
    font-weight: 500;
    color: var(--vlist-text, #111827);
}

/* ============================================================================
   Coordinates Cell
   ============================================================================ */

.table-coords {
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
    color: var(--text-muted, #6b7280);
    letter-spacing: -0.01em;
}

/* ============================================================================
   City Detail (panel) — selected city card
   ============================================================================ */

.table-detail__cc {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 28px;
    border-radius: 4px;
    background: var(--bg-subtle, #f3f4f6);
    color: var(--text-muted, #6b7280);
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.04em;
    flex-shrink: 0;
    border: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.06));
}

.table-detail__role {
    font-size: 12px;
    color: var(--text-muted);
}

/* ============================================================================
   Sort Info (panel)
   ============================================================================ */

.sort-info {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
}

.sort-info__key {
    font-weight: 600;
    color: var(--vlist-text, #111827);
    text-transform: capitalize;
}

.sort-info__dir {
    font-size: 12px;
    color: var(--text-muted);
}

/* ============================================================================
   Form Controls — input, select
   ============================================================================ */

.ui-input,
.ui-select {
    width: 100%;
    height: 32px;
    padding: 0 10px;
    border: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.12));
    border-radius: 6px;
    background: var(--bg-card, #fff);
    color: var(--vlist-text, #111827);
    font-size: 0.8125rem;
    font-family: inherit;
    outline: none;
    transition:
        border-color 0.15s ease,
        box-shadow 0.15s ease;
    box-sizing: border-box;
}

.ui-input::placeholder {
    color: var(--text-muted, #9ca3af);
}

.ui-input:focus,
.ui-select:focus {
    border-color: var(--vlist-accent, #3b82f6);
    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}

.ui-select {
    appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 8px center;
    padding-right: 28px;
    cursor: pointer;
}

/* ============================================================================
   Responsive — table takes more room on wide screens
   ============================================================================ */

.split-main--full #list-container {
    height: 600px;
}

@media (min-width: 1200px) {
    .split-main--full #list-container {
        height: 600px;
    }
}

@media (max-width: 820px) {
    .split-main--full #list-container {
        height: 480px;
    }
}
<div class="container">
    <header>
        <h1>Data Table</h1>
        <p class="description">
            Virtualized data table combining <code>withTable</code> +
            <code>withAsync</code>, backed by a real
            <strong>SQLite database</strong> with 33,352 cities from 210
            countries. Data loads <strong>lazily in chunks</strong> as you
            scroll — sorting, filtering, and search all happen
            <strong>server-side</strong> via <code>/api/cities</code>.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main split-main--full">
            <h2 class="sr-only">World Cities</h2>
            <div id="list-container"></div>
        </div>

        <aside class="split-panel">
            <!-- Search -->
            <section class="ui-section">
                <h3 class="ui-title">Search</h3>
                <div class="ui-row">
                    <input
                        type="text"
                        id="search-input"
                        class="ui-input"
                        placeholder="Search cities…"
                        spellcheck="false"
                        autocomplete="off"
                    />
                </div>
            </section>

            <!-- Continent Filter -->
            <!--<section class="ui-section">
                <h3 class="ui-title">Continent</h3>
                <div class="ui-row">
                    <select id="filter-continent" class="ui-select">
                        <option value="">All continents</option>
                    </select>
                </div>
            </section>-->

            <!-- Columns -->
            <section class="ui-section">
                <h3 class="ui-title">Columns</h3>
                <div class="ui-row">
                    <div class="ui-segmented" id="column-preset">
                        <button class="ui-segmented__btn" data-preset="default">
                            Default
                        </button>
                        <button class="ui-segmented__btn" data-preset="compact">
                            Compact
                        </button>
                        <button class="ui-segmented__btn" data-preset="full">
                            Full
                        </button>
                    </div>
                </div>
            </section>

            <!-- Row Height -->
            <section class="ui-section">
                <h3 class="ui-title">Row Height</h3>
                <div class="ui-row slider">
                    <input
                        type="range"
                        id="row-height"
                        class="ui-slider"
                        min="28"
                        max="64"
                        value="36"
                    />
                    <span class="ui-value" id="row-height-value">36px</span>
                </div>
            </section>

            <!-- Borders -->
            <section class="ui-section">
                <h3 class="ui-title">Borders</h3>
                <div class="ui-row">
                    <div class="ui-segmented" id="border-mode">
                        <button
                            class="ui-segmented__btn ui-segmented__btn--active"
                            data-mode="both"
                        >
                            Rows + Cols
                        </button>
                        <button class="ui-segmented__btn" data-mode="rows">
                            Rows
                        </button>
                        <button class="ui-segmented__btn" data-mode="striped">
                            Striped
                        </button>
                    </div>
                </div>
            </section>

            <!-- Navigation -->
            <section class="ui-section">
                <h3 class="ui-title">Navigation</h3>
                <div class="ui-row">
                    <div class="ui-btn-group">
                        <button
                            id="btn-first"
                            class="ui-btn ui-btn--icon"
                            title="First row"
                        >
                            <i class="icon icon--up"></i>
                        </button>
                        <button
                            id="btn-middle"
                            class="ui-btn ui-btn--icon"
                            title="Middle"
                        >
                            <i class="icon icon--center"></i>
                        </button>
                        <button
                            id="btn-last"
                            class="ui-btn ui-btn--icon"
                            title="Last row"
                        >
                            <i class="icon icon--down"></i>
                        </button>
                        <button
                            id="btn-random"
                            class="ui-btn ui-btn--icon"
                            title="Random row"
                        >
                            <i class="icon icon--shuffle"></i>
                        </button>
                    </div>
                </div>
            </section>

            <!-- Sort State -->
            <section class="ui-section">
                <h3 class="ui-title">Sort</h3>
                <div class="ui-detail" id="sort-detail">
                    <span class="ui-detail__empty"
                        >Click a column header to sort</span
                    >
                </div>
            </section>

            <!-- Selected Row -->
            <section class="ui-section">
                <h3 class="ui-title">Selected city</h3>
                <div class="ui-detail" id="row-detail">
                    <span class="ui-detail__empty"
                        >Click a row to see details</span
                    >
                </div>
            </section>
        </aside>
    </div>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">rows</span>
            </span>
        </div>
        <div class="example-info__right">
            <span class="example-info__stat">
                <strong id="info-results">0</strong>
                <span class="example-info__unit">results</span>
            </span>
            <span class="example-info__stat">
                <strong id="info-loaded">0</strong>
                <span class="example-info__unit">loaded</span>
            </span>
            <span class="example-info__stat">
                <strong id="info-requests">0</strong>
                <span class="example-info__unit">reqs</span>
            </span>
            <span class="example-info__stat">
                <strong id="info-columns">0</strong>
                <span class="example-info__unit">cols</span>
            </span>
            <span class="example-info__stat">
                <strong id="info-sort">none</strong>
            </span>
        </div>
    </div>
</div>