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>