groupsselection
Contact List
A–Z grouped contacts with sticky section headers using
withGroups. Headers stick to the top and get pushed out
by the next group — just like iOS Contacts.
Source
// Contact List — A–Z grouped contacts with sticky section headers
// Demonstrates withGroups plugin with sticky/inline toggle
// and withSelection for click-to-select with detail panel
import { vlist, withGroups, withSelection } from "vlist";
import { makeContacts } from "../../src/data/people.js";
import { createStats } from "../stats.js";
import { initLetterGrid } from "./controls.js";
// =============================================================================
// Constants
// =============================================================================
export const TOTAL_CONTACTS = 1000;
export const ITEM_HEIGHT = 64;
export const HEADER_HEIGHT = 36;
// =============================================================================
// Data — sorted by last name
// =============================================================================
export const contacts = makeContacts(TOTAL_CONTACTS).sort((a, b) =>
a.lastName.localeCompare(b.lastName),
);
// Build group index: letter → first contact index
export const groupIndex = new Map();
for (let i = 0; i < contacts.length; i++) {
const letter = contacts[i].lastName[0].toUpperCase();
if (!groupIndex.has(letter)) {
groupIndex.set(letter, i);
}
}
export const sortedGroups = [...groupIndex.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
);
// =============================================================================
// State — exported so controls.js can read/write
// =============================================================================
export let currentHeaderMode = "sticky"; // "sticky" | "inline" | "off"
export let list = null;
export function setCurrentHeaderMode(v) {
currentHeaderMode = v;
}
// =============================================================================
// Templates
// =============================================================================
const renderContact = (item) => `
<div class="contact">
<div class="contact__avatar" style="background:${item.color}">${item.initials}</div>
<div class="contact__info">
<div class="contact__name">${item.firstName} ${item.lastName}</div>
<div class="contact__detail">${item.department} · ${item.email}</div>
</div>
</div>
`;
const renderGroupHeader = (group) => `
<div class="group-header">
<span class="group-header__letter">${group}</span>
<span class="group-header__line"></span>
</div>
`;
// =============================================================================
// Stats — shared footer (progress, velocity, visible/total)
// =============================================================================
export const stats = createStats({
getList: () => list,
getTotal: () => contacts.length,
getItemHeight: () => ITEM_HEIGHT,
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 builder = vlist({
container: "#list-container",
ariaLabel: "Contact list",
item: {
height: ITEM_HEIGHT,
template: renderContact,
},
items: contacts,
});
if (currentHeaderMode !== "off") {
builder.use(
withGroups({
getGroupForIndex: (index) => contacts[index].lastName[0].toUpperCase(),
headerHeight: HEADER_HEIGHT,
headerTemplate: (group) => renderGroupHeader(group),
sticky: currentHeaderMode === "sticky",
}),
);
}
builder.use(withSelection({ mode: "single" }));
list = builder.build();
list.on("scroll", stats.scheduleUpdate);
list.on("range:change", ({ range }) => {
firstVisibleIndex = range.start;
stats.scheduleUpdate();
});
list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));
list.on("selection:change", ({ selected, items }) => {
if (items.length > 0) {
showContactDetail(items[0]);
} else {
clearContactDetail();
}
});
// Restore scroll position
if (firstVisibleIndex > 0) {
list.scrollToIndex(firstVisibleIndex, "start");
}
stats.update();
updateContext();
}
// =============================================================================
// Contact detail (panel) — shows selected contact
// =============================================================================
const detailEl = document.getElementById("contact-detail");
function showContactDetail(contact) {
detailEl.innerHTML = `
<div class="panel-detail__header">
<div class="contact-detail__avatar" style="background:${contact.color}">${contact.initials}</div>
<div>
<div class="panel-detail__name">${contact.firstName} ${contact.lastName}</div>
<div class="contact-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 clearContactDetail() {
detailEl.innerHTML = `
<span class="panel-detail__empty">Click a contact to see details</span>
`;
}
// =============================================================================
// Footer — right side (contextual)
// =============================================================================
const ftGroups = document.getElementById("ft-groups");
const ftHeaders = document.getElementById("ft-headers");
export function updateContext() {
ftGroups.textContent = sortedGroups.length;
ftHeaders.textContent = currentHeaderMode;
}
// =============================================================================
// Distribution chart (rendered once in panel)
// =============================================================================
const groupDistEl = document.getElementById("group-distribution");
const groupCounts = new Map();
for (const contact of contacts) {
const letter = contact.lastName[0].toUpperCase();
groupCounts.set(letter, (groupCounts.get(letter) || 0) + 1);
}
const maxGroupSize = Math.max(...[...groupCounts.values()]);
groupDistEl.innerHTML = `<div class="dist-grid">${sortedGroups
.map(([letter]) => {
const count = groupCounts.get(letter) || 0;
const barHeight = Math.round((count / maxGroupSize) * 100);
return `
<div class="dist-col" title="${letter}: ${count} contacts">
<div class="dist-bar-wrap">
<div class="dist-bar" style="height:${barHeight}%"></div>
</div>
<span class="dist-letter">${letter}</span>
</div>
`;
})
.join("")}</div>`;
// =============================================================================
// Initialise
// =============================================================================
initLetterGrid();
createList();
/* Contact List — example styles */
/* ============================================================================
vlist item overrides
============================================================================ */
#list-container .vlist-item {
padding: 0;
border-bottom: none;
}
#list-container .vlist-item[data-id^="__group_header_"] {
cursor: default;
}
#list-container .vlist-item[data-id^="__group_header_"]:hover {
background-color: var(--vlist-group-header-bg, #f3f4f6);
}
/* ============================================================================
Group Header (inline & sticky)
============================================================================ */
.group-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
height: 100%;
background: var(--vlist-group-header-bg, #f3f4f6);
}
.group-header__letter {
font-size: 13px;
font-weight: 700;
color: var(--accent-text);
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 20px;
text-align: center;
flex-shrink: 0;
}
.group-header__line {
flex: 1;
height: 1px;
background: var(--vlist-border, #e5e7eb);
}
/* ============================================================================
Contact Item (64px)
============================================================================ */
.contact {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
height: 100%;
}
.contact__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;
}
.contact__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.contact__name {
font-weight: 600;
font-size: 14px;
color: var(--vlist-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact__detail {
font-size: 12px;
color: var(--vlist-text-muted, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact__phone {
font-size: 12px;
color: var(--vlist-text-muted, #6b7280);
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 600px) {
.contact__phone {
display: none;
}
}
/* ============================================================================
Letter Grid — jump-to-letter buttons in panel
============================================================================ */
.letter-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.letter-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: var(--examples-radius, 6px);
background: var(--bg);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.letter-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.letter-btn:active {
transform: scale(0.93);
}
/* ============================================================================
Group Distribution Bar Chart (in panel)
============================================================================ */
.group-distribution {
padding: 0;
}
.dist-grid {
display: flex;
gap: 2px;
align-items: flex-end;
height: 60px;
}
.dist-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 0;
}
.dist-bar-wrap {
width: 100%;
height: 44px;
display: flex;
align-items: flex-end;
}
.dist-bar {
width: 100%;
min-height: 2px;
background: linear-gradient(
180deg,
var(--accent, #667eea) 0%,
#764ba2 100%
);
border-radius: 2px 2px 0 0;
transition: height 0.3s ease;
}
.dist-letter {
font-size: 9px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
}
/* ============================================================================
Contact Detail (panel) — selected contact card
============================================================================ */
.contact-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;
}
.contact-detail__role {
font-size: 12px;
color: var(--text-muted);
}
/* ============================================================================
Sticky header styling overrides
============================================================================ */
.vlist-sticky-header {
display: flex;
align-items: center;
}
.vlist-sticky-header .group-header {
width: 100%;
}
<div class="container">
<header>
<h1>Contact List</h1>
<p class="description">
A–Z grouped contacts with sticky section headers using
<code>withGroups</code>. Headers stick to the top and get pushed out
by the next group — just like iOS Contacts.
</p>
</header>
<div class="split-layout">
<div class="split-main">
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Sections -->
<section class="panel-section">
<h3 class="panel-title">Headers</h3>
<div class="panel-row">
<div class="panel-segmented" id="header-mode">
<button
class="panel-segmented__btn panel-segmented__btn--active"
data-mode="sticky"
>
Sticky
</button>
<button class="panel-segmented__btn" data-mode="inline">
Inline
</button>
<button class="panel-segmented__btn" data-mode="off">
Off
</button>
</div>
</div>
</section>
<!-- Jump to Letter -->
<section class="panel-section">
<h3 class="panel-title">Jump to Letter</h3>
<div class="panel-row">
<div class="letter-grid" id="letter-grid"></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"
>
<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"
>
<i class="icon icon--down"></i>
</button>
<button
id="btn-random"
class="panel-btn panel-btn--icon"
title="Random group"
>
<i class="icon icon--shuffle"></i>
</button>
</div>
</div>
</section>
<!-- Distribution -->
<section class="panel-section">
<h3 class="panel-title">Distribution</h3>
<div class="group-distribution" id="group-distribution"></div>
</section>
<!-- Selected Contact -->
<section class="panel-section">
<h3 class="panel-title">Selected contact</h3>
<div class="panel-detail" id="contact-detail">
<span class="panel-detail__empty"
>Click a contact 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">items</span>
</span>
</div>
<div class="example-footer__right">
<span class="example-footer__stat">
<strong id="ft-groups">0</strong>
<span class="example-footer__unit">groups</span>
</span>
<span class="example-footer__stat">
<strong id="ft-headers">sticky</strong>
</span>
</div>
</footer>
</div>