asyncselectiontablegrid
Track List
SQLite-backed music database with CRUD operations
Tracks
0%
0.00 /
0.00
px/ms
0 /
0
items
list
Source
// Track List - Pure Vanilla JavaScript with Async Loading
// Demonstrates vlist with lazy-loaded SQLite data, fetching tracks in chunks of 25
// Layout mode toggle: List ↔ Grid ↔ Table
import {
vlist,
withSelection,
withAsync,
withGrid,
withTable,
withScrollbar,
withScale,
} from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import {
API_BASE,
trackTemplate,
trackGridTemplate,
trackTableColumns,
trackTableRowTemplate,
formatSelectionCount,
formatDuration,
escapeHtml,
} from "./shared.js";
// =============================================================================
// Constants
// =============================================================================
const CHUNK_SIZE = 25;
const ITEM_HEIGHT = 56;
const GRID_COLUMNS = 4;
const GRID_GAP = 8;
const TABLE_ROW_HEIGHT = 36;
const TABLE_HEADER_HEIGHT = 36;
// =============================================================================
// State
// =============================================================================
let list = null;
let totalTracks = 0;
let currentSelectionMode = "single";
let currentLayoutMode = "list";
let currentScrollbarEnabled = false;
let currentScaleEnabled = false;
let loadRequests = 0;
let loadedCount = 0;
let currentFilters = {
search: "",
country: "",
decade: "",
};
// =============================================================================
// Adapter - fetches tracks from SQLite API in chunks
// =============================================================================
function buildParams(offset, limit) {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
sort: "id",
direction: "desc",
});
if (currentFilters.search) params.set("search", currentFilters.search);
if (currentFilters.country) params.set("country", currentFilters.country);
if (currentFilters.decade) params.set("decade", currentFilters.decade);
return params;
}
const tracksAdapter = {
read: async ({ offset, limit }) => {
loadRequests++;
const params = buildParams(offset, limit);
const res = await fetch(`${API_BASE}?${params}`);
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
totalTracks = data.total;
loadedCount += data.items.length;
return {
items: data.items,
total: data.total,
hasMore: data.hasMore,
};
},
};
// =============================================================================
// Stats — info bar (progress, velocity, visible/total)
// =============================================================================
function getEffectiveItemHeight() {
if (currentLayoutMode === "table") return TABLE_ROW_HEIGHT;
if (currentLayoutMode === "grid") {
const container = document.getElementById("list-container");
if (!container) return 200;
const innerWidth = container.clientWidth - 2;
const colWidth =
(innerWidth - (GRID_COLUMNS - 1) * GRID_GAP) / GRID_COLUMNS;
return Math.round(colWidth * 1.3);
}
return ITEM_HEIGHT;
}
const stats = createStats({
getScrollPosition: () => list?.getScrollPosition() ?? 0,
getTotal: () => totalTracks,
getItemSize: () => getEffectiveItemHeight(),
getColumns: () => (currentLayoutMode === "grid" ? GRID_COLUMNS : 1),
getContainerSize: () => {
const el = document.getElementById("list-container");
return el ? el.clientHeight : 0;
},
});
const updateInfo = createInfoUpdater(stats);
// =============================================================================
// DOM References
// =============================================================================
// Layout
const layoutModeEl = document.getElementById("layout-mode");
const scrollbarToggle = document.getElementById("scrollbar-toggle");
const scaleToggle = document.getElementById("scale-toggle");
// Selection
const selectionModeEl = document.getElementById("selection-mode");
const btnSelectAll = document.getElementById("btn-select-all");
const btnClear = document.getElementById("btn-clear");
const selectionCountEl = document.getElementById("selection-count");
// Actions
const btnAddTrack = document.getElementById("btn-add-track");
const btnDeleteSelected = document.getElementById("btn-delete-selected");
// =============================================================================
// Async plugin config (shared across all modes)
// =============================================================================
function getAsyncConfig() {
return {
adapter: tracksAdapter,
autoLoad: true,
storage: {
chunkSize: CHUNK_SIZE,
maxCachedItems: 2000,
},
loading: {
cancelThreshold: 8,
preloadThreshold: 2,
preloadAhead: 25,
},
};
}
// =============================================================================
// Create List — dispatches to the correct view builder
// =============================================================================
function createList(selectionMode) {
if (list) {
list.destroy();
}
loadRequests = 0;
loadedCount = 0;
const container = document.getElementById("list-container");
container.innerHTML = "";
if (currentLayoutMode === "grid") {
createGridView(selectionMode);
} else if (currentLayoutMode === "table") {
createTableView(selectionMode);
} else {
createListView(selectionMode);
}
bindListEvents();
updateInfo();
updateContext();
}
// =============================================================================
// Apply scrollbar feature to builder if enabled
// =============================================================================
function applyScrollbar(builder) {
if (currentScrollbarEnabled) {
builder.use(
withScrollbar({
autoHide: true,
autoHideDelay: 1000,
showOnHover: true,
showOnViewportEnter: true,
}),
);
}
return builder;
}
// =============================================================================
// Apply scale feature to builder if enabled
// =============================================================================
function applyScale(builder) {
if (currentScaleEnabled) {
builder.use(withScale({ force: true }));
}
return builder;
}
// =============================================================================
// List View (default — vertical list with 80px rows)
// =============================================================================
function createListView(selectionMode) {
const builder = vlist({
container: "#list-container",
ariaLabel: "Track list",
item: {
height: ITEM_HEIGHT,
template: trackTemplate,
},
});
builder.use(withAsync(getAsyncConfig()));
applyScale(builder);
applyScrollbar(builder);
builder.use(withSelection({ mode: selectionMode }));
list = builder.build();
}
// =============================================================================
// Grid View (withGrid — card layout)
// =============================================================================
function createGridView(selectionMode) {
const container = document.getElementById("list-container");
const innerWidth = container.clientWidth - 2;
const colWidth = (innerWidth - (GRID_COLUMNS - 1) * GRID_GAP) / GRID_COLUMNS;
const cardHeight = Math.round(colWidth * 1.3);
const builder = vlist({
container: "#list-container",
ariaLabel: "Track list",
item: {
height: (_index, ctx) =>
ctx ? Math.round(ctx.columnWidth * 1.3) : cardHeight,
template: trackGridTemplate,
},
});
builder.use(withAsync(getAsyncConfig()));
builder.use(withGrid({ columns: GRID_COLUMNS, gap: GRID_GAP }));
applyScale(builder);
applyScrollbar(builder);
builder.use(withSelection({ mode: selectionMode }));
list = builder.build();
}
function createTableView(selectionMode) {
const builder = vlist({
container: "#list-container",
ariaLabel: "Track list",
item: {
height: TABLE_ROW_HEIGHT,
striped: "odd",
template: trackTableRowTemplate,
},
});
builder.use(withAsync(getAsyncConfig()));
builder.use(
withTable({
columns: trackTableColumns,
rowHeight: TABLE_ROW_HEIGHT,
headerHeight: TABLE_HEADER_HEIGHT,
resizable: true,
columnBorders: false,
rowBorders: false,
minColumnWidth: 50,
}),
);
applyScale(builder);
applyScrollbar(builder);
builder.use(withSelection({ mode: selectionMode }));
list = builder.build();
}
// =============================================================================
// Table View (withTable — columns with header)
// =============================================================================
function createTableView(selectionMode) {
const builder = vlist({
container: "#list-container",
ariaLabel: "Track list",
item: {
height: TABLE_ROW_HEIGHT,
striped: "odd",
template: trackTableRowTemplate,
},
});
builder.use(withAsync(getAsyncConfig()));
builder.use(
withTable({
columns: trackTableColumns,
rowHeight: TABLE_ROW_HEIGHT,
headerHeight: TABLE_HEADER_HEIGHT,
resizable: true,
columnBorders: false,
rowBorders: false,
minColumnWidth: 50,
}),
);
builder.use(withSelection({ mode: selectionMode }));
list = builder.build();
}
// =============================================================================
// Layout Mode — List ↔ Grid ↔ Table
// =============================================================================
function setLayoutMode(mode) {
if (mode === currentLayoutMode) return;
currentLayoutMode = mode;
layoutModeEl.querySelectorAll(".ui-segmented__btn").forEach((btn) => {
btn.classList.toggle(
"ui-segmented__btn--active",
btn.dataset.mode === mode,
);
});
// Toggle container class for mode-specific styling
const container = document.getElementById("list-container");
container.classList.remove("mode-list", "mode-grid", "mode-table");
container.classList.add(`mode-${mode}`);
// Grid/table need more width — add split-main--full
const splitMain = container.closest(".split-main");
if (splitMain) {
splitMain.classList.toggle("split-main--full", mode !== "list");
}
createList(currentSelectionMode);
updateSelectionCount([]);
}
// =============================================================================
// Info bar — right side (contextual)
// =============================================================================
const infoMode = document.getElementById("info-mode");
function updateContext() {
if (infoMode) infoMode.textContent = currentLayoutMode;
}
layoutModeEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mode]");
if (btn) setLayoutMode(btn.dataset.mode);
});
// =============================================================================
// Scrollbar Toggle
// =============================================================================
scrollbarToggle.addEventListener("change", (e) => {
currentScrollbarEnabled = e.target.checked;
createList(currentSelectionMode);
});
// =============================================================================
// Scale Toggle
// =============================================================================
scaleToggle.addEventListener("change", (e) => {
currentScaleEnabled = e.target.checked;
// Scale forces custom scrollbar — lock the toggle on when scale is active
if (currentScaleEnabled) {
scrollbarToggle.checked = true;
scrollbarToggle.disabled = true;
currentScrollbarEnabled = true;
} else {
scrollbarToggle.disabled = false;
}
createList(currentSelectionMode);
});
// =============================================================================
// Selection
// =============================================================================
function setSelectionMode(mode) {
if (mode === currentSelectionMode) return;
currentSelectionMode = mode;
selectionModeEl.querySelectorAll(".ui-segmented__btn").forEach((btn) => {
btn.classList.toggle(
"ui-segmented__btn--active",
btn.dataset.value === mode,
);
});
createList(mode);
updateSelectionCount([]);
}
selectionModeEl.addEventListener("click", (e) => {
const btn = e.target.closest(".ui-segmented__btn");
if (btn) setSelectionMode(btn.dataset.value);
});
btnSelectAll.addEventListener("click", () => {
if (currentSelectionMode !== "multiple") {
setSelectionMode("multiple");
// List was recreated — wait for async data to load before selecting
const unsub = list.on("load:end", () => {
unsub();
list.selectAll();
});
} else {
list.selectAll();
}
});
btnClear.addEventListener("click", () => {
list.clearSelection();
});
function updateSelectionCount(selected) {
selectionCountEl.textContent = formatSelectionCount(selected.length);
btnDeleteSelected.disabled = selected.length === 0;
}
// =============================================================================
// CRUD Operations
// =============================================================================
btnAddTrack.addEventListener("click", async () => {
const title = prompt("Track title:");
if (!title) return;
const artist = prompt("Artist name:");
if (!artist) return;
const year = prompt("Year (optional):");
const country = prompt("Country code (optional, e.g., USA):");
const trackData = {
title,
artist,
year: year ? parseInt(year, 10) : null,
country: country || null,
};
try {
const response = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(trackData),
});
if (!response.ok) throw new Error("Failed to create track");
alert("Track created successfully!");
loadedCount = 0;
await list.reload();
} catch (error) {
alert("Failed to create track: " + error.message);
}
});
const MAX_DELETE = 25;
async function deleteSelected() {
const selected = list.getSelected();
if (selected.length === 0) return;
if (selected.length > MAX_DELETE) {
alert(
`You can delete up to ${MAX_DELETE} tracks at a time to keep the demo functional.`,
);
return;
}
const items = list.getSelectedItems();
// Delete from server
const promises = items.map((track) =>
fetch(`${API_BASE}/${track.id}`, { method: "DELETE" }),
);
await Promise.all(promises);
// Remove each item from the list, tracking the lowest index for auto-select.
// We delete from highest index to lowest so earlier deletions don't shift
// the indices of later ones.
let lowestDeletedIndex = Infinity;
// Build id→index map before any deletion
const deleteOrder = items
.map((track) => {
const container = list.element;
const el = container?.querySelector(`[data-id="${track.id}"]`);
const index = el ? parseInt(el.dataset.index, 10) : -1;
return { track, index };
})
.filter((e) => e.index >= 0)
.sort((a, b) => b.index - a.index); // highest index first
deleteOrder.forEach(({ track, index }) => {
const result = list.removeItem(track.id);
if (result && index < lowestDeletedIndex) {
lowestDeletedIndex = index;
}
});
totalTracks = list.total;
list.clearSelection();
updateInfo();
// Auto-select the item that is now at the deleted position
if (
list.total > 0 &&
currentSelectionMode !== "none" &&
lowestDeletedIndex < Infinity
) {
const targetIndex = Math.min(lowestDeletedIndex, list.total - 1);
requestAnimationFrame(() => {
const container = list.element;
const el = container?.querySelector(`[data-index="${targetIndex}"]`);
const id = el?.dataset.id;
if (id && !id.startsWith("__placeholder_")) {
const numId = Number(id);
const selectId = Number.isFinite(numId) ? numId : id;
list.select(selectId);
}
});
}
}
btnDeleteSelected.addEventListener("click", deleteSelected);
// Keyboard shortcut: Delete/Backspace to delete selected item
document.addEventListener("keydown", (e) => {
if (e.key === "Delete" || e.key === "Backspace") {
// Don't trigger if user is typing in an input
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
e.preventDefault();
deleteSelected();
}
});
// =============================================================================
// Event Bindings
// =============================================================================
function bindListEvents() {
list.on("selection:change", ({ selected }) => {
updateSelectionCount(selected);
});
list.on("load:end", ({ items, total }) => {
totalTracks = total;
updateInfo();
});
list.on("scroll", updateInfo);
list.on("range:change", updateInfo);
list.on("velocity:change", ({ velocity }) => {
stats.onVelocity(velocity);
updateInfo();
});
}
// =============================================================================
// Initialize
// =============================================================================
async function init() {
createList(currentSelectionMode);
}
init();
/* Track List — Music playlist styles using design tokens */
/* ============================================================================
List Container
============================================================================ */
#list-container {
height: 600px;
overflow: hidden;
}
.ui-segmented {
flex: none;
}
/* ============================================================================
Track Item Styles
============================================================================ */
.vlist-item {
border-bottom: 1px solid var(--border);
transition: background-color 0.2s ease;
}
.vlist-item.vlist-item--selected {
background-color: var(--badge-error-bg);
}
.vlist-item.vlist-item--selected:hover {
background-color: var(--badge-error-bg);
opacity: 0.8;
}
.item-content {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 6px 12px;
height: 100%;
overflow: hidden;
}
/* Album artwork container */
.item-artwork {
width: 36px;
height: 36px;
border-radius: var(--radius-xs);
flex: 0 0 36px;
overflow: hidden;
position: relative;
}
/* Cover image (list mode) */
.item-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: var(--radius-xs);
background: var(--vlist-placeholder-bg);
opacity: 1;
transition: opacity 0.4s ease;
}
.item-cover.item-cover--loaded {
opacity: 1;
}
/* Initials fallback (list mode) */
.item-cover--fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--vlist-placeholder-bg);
color: var(--text);
font-weight: 600;
font-size: 13px;
border-radius: var(--radius-xs);
}
/* Track details */
.item-details {
flex: 1 1 0%;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 2px;
}
.item-title {
font-weight: 500;
font-size: var(--fs-sm);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.item-artist {
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.item-meta {
font-size: var(--fs-xs);
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-separator {
color: var(--text-dim);
}
.item-year,
.item-country,
.item-duration {
color: var(--text-dim);
}
/* Duration (right side) */
.item-duration-main {
flex: 0 0 auto;
font-size: var(--fs-xs);
color: var(--text-dim);
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Three-dot menu */
.item-menu {
flex: 0 0 auto;
opacity: 0;
transition: opacity 0.15s ease;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
font-size: var(--fs-xs);
}
.vlist-item:hover .item-menu {
opacity: 1;
}
.item-menu:hover {
color: var(--text);
}
/* ============================================================================
Grid Mode — Card Layout
============================================================================ */
.vlist-grid-item.vlist-item--selected {
background-color: transparent !important;
}
.vlist-grid-item.vlist-item--selected .grid-card {
border: 1px solid var(--accent);
background-color: var(--badge-error-bg);
}
.grid-card {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
transition: border-color 0.2s ease;
}
.grid-card:hover {
border-color: var(--text-muted);
}
.vlist-item--selected .grid-card {
border-color: var(--badge-error-bg);
box-shadow: 0 0 0 1px var(--badge-error-bg);
}
.grid-card__artwork {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
}
/* Cover image (grid mode) */
.grid-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.4s ease;
}
.grid-cover.grid-cover--loaded {
opacity: 1;
}
/* Initials fallback (grid mode) */
.grid-cover--fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
font-weight: 700;
font-size: 28px;
letter-spacing: 1px;
}
.grid-card__body {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
border-top: 1px solid var(--border);
}
.grid-card__title {
font-weight: 500;
font-size: var(--fs-sm);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.grid-card__artist {
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.grid-card__meta {
display: flex;
gap: 6px;
font-size: var(--fs-xs);
color: var(--text-dim);
margin-top: 2px;
}
/* Grid mode: remove list-style borders on items */
.mode-grid .vlist-item {
border-bottom: none;
}
/* ============================================================================
Table Mode — Column Cells
============================================================================ */
.table-cell--title {
font-weight: 500;
}
/* Table mode: tighter selection highlight */
.mode-table .vlist-item.vlist-item--selected {
background-color: var(--badge-error-bg);
}
/* ============================================================================
Placeholder skeleton — driven by .vlist-item--placeholder on the wrapper.
The template is identical for real and placeholder items; mask characters
(x) set the natural width, CSS hides them and shows skeleton blocks.
============================================================================ */
/* List mode placeholders */
.vlist-item--placeholder .item-title {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: fit-content;
line-height: 1.1;
margin-bottom: 1px;
}
.vlist-item--placeholder .item-artist {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: fit-content;
line-height: 1;
}
.vlist-item--placeholder .item-duration-main {
color: transparent;
}
.vlist-item--placeholder .item-menu {
visibility: hidden;
}
/* Grid mode placeholders */
.vlist-item--placeholder .grid-cover--fallback {
color: transparent;
}
.vlist-item--placeholder .grid-card__title {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: fit-content;
line-height: 1.1;
margin-bottom: 1px;
}
.vlist-item--placeholder .grid-card__artist {
color: transparent;
background-color: var(--vlist-placeholder-bg);
border-radius: 4px;
width: fit-content;
line-height: 1;
}
.vlist-item--placeholder .grid-card__meta {
visibility: hidden;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 820px) {
#list-container {
height: 400px;
}
.item-artwork {
width: 30px;
height: 30px;
flex-basis: 30px;
}
.item-cover--fallback {
font-size: 11px;
}
.item-meta {
display: none;
}
}
<div class="container">
<header>
<h1>Track List</h1>
<p class="description">
SQLite-backed music database with CRUD operations
</p>
</header>
<div class="split-layout">
<div class="split-main">
<h2 class="sr-only">Tracks</h2>
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Layout -->
<section class="ui-section">
<h3 class="ui-title">Layout</h3>
<div class="ui-row">
<label class="ui-label">Mode</label>
<div class="ui-segmented" id="layout-mode">
<button
class="ui-segmented__btn ui-segmented__btn--active"
data-mode="list"
>
List
</button>
<button class="ui-segmented__btn" data-mode="grid">
Grid
</button>
<button class="ui-segmented__btn" data-mode="table">
Table
</button>
</div>
</div>
<div class="ui-row">
<label class="ui-label">Custom Scrollbar</label>
<label class="ui-switch">
<input type="checkbox" id="scrollbar-toggle" />
<span class="ui-switch__track"></span>
</label>
</div>
<div class="ui-row">
<label class="ui-label">Scale</label>
<label class="ui-switch">
<input type="checkbox" id="scale-toggle" />
<span class="ui-switch__track"></span>
</label>
</div>
</section>
<!-- Selection -->
<section class="ui-section">
<h3 class="ui-title">Selection</h3>
<div class="ui-row">
<label class="ui-label">Mode</label>
<div class="ui-segmented" id="selection-mode">
<button
class="ui-segmented__btn ui-segmented__btn--active"
data-value="single"
>
single
</button>
<button class="ui-segmented__btn" data-value="multiple">
multiple
</button>
</div>
</div>
<div class="ui-row">
<label class="ui-label">Actions</label>
<div class="ui-btn-group">
<button id="btn-select-all" class="ui-btn">
Select all
</button>
<button id="btn-clear" class="ui-btn">Clear</button>
</div>
</div>
<div class="ui-row">
<span class="ui-label">Selected</span>
<span class="ui-value" id="selection-count">0 tracks</span>
</div>
</section>
<!-- CRUD Operations -->
<section class="ui-section">
<h3 class="ui-title">Operations</h3>
<div class="ui-row">
<button id="btn-add-track" class="ui-btn ui-btn--primary">
Add Track
</button>
</div>
<div class="ui-row">
<button
id="btn-delete-selected"
class="ui-btn ui-btn--danger"
disabled
>
Delete Selected
</button>
</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">items</span>
</span>
</div>
<div class="example-info__right">
<span class="example-info__stat">
<strong id="info-mode">list</strong>
</span>
</div>
</div>
</div>