tableselection
Data Table
Virtualized data table with resizable columns, sorting, and
selection using withTable. Drag column borders to
resize, click headers to sort. Handles 10 000 rows at 60 fps.
Source
// Data Table — Virtualized table with resizable columns, sorting, and selection
// Demonstrates withTable plugin with column presets, sort toggle,
// and withSelection for click-to-select with detail panel
import { vlist, withTable, withSelection } from "vlist";
import { makeContacts } from "../../src/data/people.js";
import { createStats } from "../stats.js";
import { initControls } from "./controls.js";
// =============================================================================
// Constants
// =============================================================================
export const TOTAL_ROWS = 10_000;
export const DEFAULT_ROW_HEIGHT = 36;
export const HEADER_HEIGHT = 36;
// =============================================================================
// Data
// =============================================================================
export const contacts = makeContacts(TOTAL_ROWS);
// Keep a mutable reference for sorting
export let sortedContacts = [...contacts];
// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================
export let list = null;
export let currentRowHeight = DEFAULT_ROW_HEIGHT;
export let currentPreset = "default";
export let currentBorderMode = "both";
export let sortKey = null;
export let sortDirection = "asc";
export function setCurrentRowHeight(v) {
currentRowHeight = v;
}
export function setCurrentPreset(v) {
currentPreset = v;
}
export function setCurrentBorderMode(v) {
currentBorderMode = v;
}
// =============================================================================
// Column Presets
// =============================================================================
/** Status badge cell renderer */
const statusCell = (item) => {
const active = item.id % 3 !== 0;
const label = active ? "Active" : "Inactive";
const cls = active ? "status-badge--active" : "status-badge--inactive";
return `<span class="status-badge ${cls}">${label}</span>`;
};
/** Avatar + name cell renderer */
const nameCell = (item) => `
<div class="table-name">
<div class="table-avatar" style="background:${item.color}">${item.initials}</div>
<span class="table-name__text">${item.firstName} ${item.lastName}</span>
</div>
`;
const COLUMN_PRESETS = {
default: [
{
key: "name",
label: "Name",
width: 220,
minWidth: 140,
sortable: true,
cell: nameCell,
},
{
key: "email",
label: "Email",
width: 260,
minWidth: 140,
sortable: true,
},
{
key: "department",
label: "Department",
width: 140,
minWidth: 90,
sortable: true,
},
{
key: "role",
label: "Role",
width: 180,
minWidth: 100,
sortable: true,
},
{
key: "status",
label: "Status",
width: 100,
minWidth: 80,
align: "center",
sortable: true,
cell: statusCell,
},
],
compact: [
{
key: "name",
label: "Name",
width: 200,
minWidth: 120,
sortable: true,
cell: nameCell,
},
{
key: "email",
label: "Email",
minWidth: 140,
sortable: true,
},
{
key: "department",
label: "Dept",
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: "Name",
width: 200,
minWidth: 140,
sortable: true,
cell: nameCell,
},
{
key: "email",
label: "Email",
width: 240,
minWidth: 140,
sortable: true,
},
{
key: "company",
label: "Company",
width: 160,
minWidth: 100,
sortable: true,
},
{
key: "department",
label: "Department",
width: 130,
minWidth: 90,
sortable: true,
},
{
key: "role",
label: "Role",
width: 170,
minWidth: 100,
sortable: true,
},
{
key: "city",
label: "City",
width: 120,
minWidth: 80,
sortable: true,
},
{
key: "country",
label: "Country",
width: 130,
minWidth: 80,
sortable: true,
},
{
key: "phone",
label: "Phone",
width: 140,
minWidth: 110,
},
{
key: "status",
label: "Status",
width: 100,
minWidth: 80,
align: "center",
sortable: true,
cell: statusCell,
},
],
};
export function getColumns() {
return COLUMN_PRESETS[currentPreset] || COLUMN_PRESETS.default;
}
// =============================================================================
// Sorting
// =============================================================================
/**
* Sort contacts by a given key and direction.
* Returns a new sorted array (does not mutate the original).
*/
function sortContacts(key, direction) {
const dir = direction === "desc" ? -1 : 1;
return [...contacts].sort((a, b) => {
let aVal, bVal;
if (key === "name") {
aVal = a.lastName + a.firstName;
bVal = b.lastName + b.firstName;
} else if (key === "status") {
aVal = a.id % 3 !== 0 ? "Active" : "Inactive";
bVal = b.id % 3 !== 0 ? "Active" : "Inactive";
} else {
aVal = a[key];
bVal = b[key];
}
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === "number" && typeof bVal === "number") {
return (aVal - bVal) * dir;
}
return String(aVal).localeCompare(String(bVal)) * dir;
});
}
export function applySort(key, direction) {
sortKey = key;
sortDirection = direction || "asc";
if (key === null) {
sortedContacts = [...contacts];
} else {
sortedContacts = sortContacts(key, direction);
}
if (list) {
list.setItems(sortedContacts);
}
updateContext();
updateSortDetail();
}
// =============================================================================
// Templates (fallback — cell templates are defined per column above)
// =============================================================================
const fallbackTemplate = () => "";
// =============================================================================
// Stats — shared footer (progress, velocity, visible/total)
// =============================================================================
export const stats = createStats({
getList: () => list,
getTotal: () => sortedContacts.length,
getItemHeight: () => currentRowHeight,
container: "#list-container",
});
// =============================================================================
// Create / Recreate list
// =============================================================================
let firstVisibleIndex = 0;
export function createList() {
if (list) {
list.destroy();
list = null;
}
const container = document.getElementById("list-container");
container.innerHTML = "";
const columns = getColumns();
const columnBorders = currentBorderMode === "both";
const rowBorders = currentBorderMode !== "none";
const builder = vlist({
container: "#list-container",
ariaLabel: "Employee data table",
item: {
height: currentRowHeight,
template: fallbackTemplate,
},
items: sortedContacts,
});
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", stats.scheduleUpdate);
list.on("range:change", ({ range }) => {
firstVisibleIndex = range.start;
stats.scheduleUpdate();
});
list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));
// Sort event — consumer handles actual sorting
list.on("column:sort", ({ key, direction }) => {
applySort(direction === null ? null : key, direction);
// Update the visual indicator on the header
if (list.setSort) {
list.setSort(sortKey, sortDirection);
}
});
// Selection event — show detail panel
list.on("selection:change", ({ selected, items }) => {
if (items.length > 0) {
showRowDetail(items[0]);
} else {
clearRowDetail();
}
});
// Restore scroll position
if (firstVisibleIndex > 0) {
list.scrollToIndex(firstVisibleIndex, "start");
}
stats.update();
updateContext();
}
// =============================================================================
// Row detail (panel) — shows selected row
// =============================================================================
const detailEl = document.getElementById("row-detail");
function showRowDetail(contact) {
if (!detailEl) return;
detailEl.innerHTML = `
<div class="panel-detail__header">
<div class="table-detail__avatar" style="background:${contact.color}">${contact.initials}</div>
<div>
<div class="panel-detail__name">${contact.firstName} ${contact.lastName}</div>
<div class="table-detail__role">${contact.role}</div>
</div>
</div>
<div class="panel-detail__meta">
<span>${contact.department} · ${contact.company}</span>
<span>${contact.email}</span>
<span>${contact.phone}</span>
<span>${contact.city}, ${contact.country}</span>
</div>
`;
}
function clearRowDetail() {
if (!detailEl) return;
detailEl.innerHTML = `
<span class="panel-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="panel-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>
`;
}
}
// =============================================================================
// Footer — right side (contextual)
// =============================================================================
const ftColumns = document.getElementById("ft-columns");
const ftSort = document.getElementById("ft-sort");
export function updateContext() {
if (ftColumns) ftColumns.textContent = getColumns().length;
if (ftSort) {
ftSort.textContent =
sortKey !== null ? `${sortKey} ${sortDirection}` : "none";
}
}
// =============================================================================
// Initialise
// =============================================================================
initControls();
createList();
/* Data Table — example styles */
/* ============================================================================
vlist item overrides
============================================================================ */
#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;
}
#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;
}
/* ============================================================================
Name Cell — avatar + name inline
============================================================================ */
.table-name {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.table-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 11px;
flex-shrink: 0;
}
.table-name__text {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
color: var(--vlist-text, #111827);
}
/* ============================================================================
Status Badge
============================================================================ */
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
line-height: 1.5;
}
.status-badge--active {
background: #dcfce7;
color: #166534;
}
.status-badge--inactive {
background: #fef2f2;
color: #991b1b;
}
[data-theme-mode="dark"] .status-badge--active {
background: rgba(22, 163, 74, 0.2);
color: #86efac;
}
[data-theme-mode="dark"] .status-badge--inactive {
background: rgba(220, 38, 38, 0.2);
color: #fca5a5;
}
/* ============================================================================
Row Detail (panel) — selected row card
============================================================================ */
.table-detail__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 13px;
flex-shrink: 0;
}
.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, #6b7280);
}
/* ============================================================================
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 with resizable columns, sorting, and
selection using <code>withTable</code>. Drag column borders to
resize, click headers to sort. Handles 10 000 rows at 60 fps.
</p>
</header>
<div class="split-layout">
<div class="split-main split-main--full">
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Columns -->
<section class="panel-section">
<h3 class="panel-title">Columns</h3>
<div class="panel-row">
<div class="panel-segmented" id="column-preset">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-preset="default"
>
Default
</button>
<button
class="panel-segmented__btn"
data-preset="compact"
>
Compact
</button>
<button class="panel-segmented__btn" data-preset="full">
Full
</button>
</div>
</div>
</section>
<!-- Row Height -->
<section class="panel-section">
<h3 class="panel-title">Row Height</h3>
<div class="panel-row slider">
<input
type="range"
id="row-height"
class="panel-slider"
min="28"
max="64"
value="40"
/>
<span class="panel-value" id="row-height-value">40px</span>
</div>
</section>
<!-- Borders -->
<section class="panel-section">
<h3 class="panel-title">Borders</h3>
<div class="panel-row">
<div class="panel-segmented" id="border-mode">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-mode="both"
>
Both
</button>
<button class="panel-segmented__btn" data-mode="rows">
Rows
</button>
<button class="panel-segmented__btn" data-mode="none">
None
</button>
</div>
</div>
</section>
<!-- Navigation -->
<section class="panel-section">
<h3 class="panel-title">Navigation</h3>
<div class="panel-row">
<div class="panel-btn-group">
<button
id="btn-first"
class="panel-btn panel-btn--icon"
title="First row"
>
<i class="icon icon--up"></i>
</button>
<button
id="btn-middle"
class="panel-btn panel-btn--icon"
title="Middle"
>
<i class="icon icon--center"></i>
</button>
<button
id="btn-last"
class="panel-btn panel-btn--icon"
title="Last row"
>
<i class="icon icon--down"></i>
</button>
<button
id="btn-random"
class="panel-btn panel-btn--icon"
title="Random row"
>
<i class="icon icon--shuffle"></i>
</button>
</div>
</div>
</section>
<!-- Sort State -->
<section class="panel-section">
<h3 class="panel-title">Sort</h3>
<div class="panel-detail" id="sort-detail">
<span class="panel-detail__empty"
>Click a column header to sort</span
>
</div>
</section>
<!-- Selected Row -->
<section class="panel-section">
<h3 class="panel-title">Selected row</h3>
<div class="panel-detail" id="row-detail">
<span class="panel-detail__empty"
>Click a row to see details</span
>
</div>
</section>
</aside>
</div>
<footer class="example-footer" id="example-footer">
<div class="example-footer__left">
<span class="example-footer__stat">
<strong id="ft-progress">0%</strong>
</span>
<span class="example-footer__stat">
<span id="ft-velocity">0.00</span> /
<strong id="ft-velocity-avg">0.00</strong>
<span class="example-footer__unit">px/ms</span>
</span>
<span class="example-footer__stat">
<span id="ft-dom">0</span> /
<strong id="ft-total">0</strong>
<span class="example-footer__unit">rows</span>
</span>
</div>
<div class="example-footer__right">
<span class="example-footer__stat">
<strong id="ft-columns">0</strong>
<span class="example-footer__unit">cols</span>
</span>
<span class="example-footer__stat">
<strong id="ft-sort">none</strong>
</span>
</div>
</footer>
</div>