aria
Accessibility -
WAI-ARIA listbox — role="listbox" on the root,
role="option" on every item,
aria-setsize / aria-posinset for
positional context, aria-activedescendant for focus
tracking, and aria-selected for selection state. Tab
into the list and use arrow keys, Space, or Enter to interact.
Disable interactive to remove all keyboard navigation.
Items
0%
0.00 /
0.00
px/ms
0 /
0
items
focused —
posinset —
Source
// Accessibility — WAI-ARIA listbox pattern demonstration
// Shows: role="listbox" / role="option", aria-setsize, aria-posinset,
// aria-activedescendant, aria-selected, keyboard navigation.
// The ARIA inspector updates live as you interact.
// Toggle "interactive" off to disable all built-in keyboard navigation.
import { vlist } from "vlist";
import { makeUsers } from "../../src/data/people.js";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import "./controls.js";
// =============================================================================
// Constants
// =============================================================================
export const TOTAL = 500;
export const ITEM_HEIGHT = 56;
// =============================================================================
// Data
// =============================================================================
export const users = makeUsers(TOTAL);
// =============================================================================
// State — exported so controls.js can read
// =============================================================================
export let list = null;
export let interactiveEnabled = true;
// =============================================================================
// Template
// =============================================================================
export const itemTemplate = (user, index) => `
<div class="item__avatar" style="background:${user.color};color:${user.textColor}">${user.initials}</div>
<div class="item__text">
<div class="item__name">${user.name}</div>
<div class="item__email">${user.email}</div>
</div>
<span class="item__index">#${index + 1}</span>
`;
// =============================================================================
// Stats — shared info bar (progress, velocity, visible/total)
// =============================================================================
export const stats = createStats({
getScrollPosition: () => list?.getScrollPosition() ?? 0,
getTotal: () => TOTAL,
getItemSize: () => ITEM_HEIGHT,
getContainerSize: () =>
document.querySelector("#list-container")?.clientHeight ?? 0,
});
const updateInfo = createInfoUpdater(stats);
// =============================================================================
// ARIA Inspector — reads live attribute values from the vlist root element
// =============================================================================
const attrRole = document.getElementById("attr-role");
const attrLabel = document.getElementById("attr-label");
const attrTabindex = document.getElementById("attr-tabindex");
const attrActiveDesc = document.getElementById("attr-activedescendant");
const attrSelected = document.getElementById("attr-selected");
const attrSetsize = document.getElementById("attr-setsize");
const attrPosinset = document.getElementById("attr-posinset");
function updateInspector() {
const container = document.getElementById("list-container");
const root = container && container.querySelector(".vlist");
if (!root) return;
attrRole.textContent = root.getAttribute("role") ?? "—";
attrLabel.textContent = root.getAttribute("aria-label") ?? "—";
attrTabindex.textContent = root.getAttribute("tabindex") ?? "—";
const activeId = root.getAttribute("aria-activedescendant");
attrActiveDesc.textContent = activeId ?? "none";
const focusedEl = activeId
? root.querySelector(`#${CSS.escape(activeId)}`)
: null;
if (focusedEl) {
attrSelected.textContent = focusedEl.getAttribute("aria-selected") ?? "—";
attrSetsize.textContent = focusedEl.getAttribute("aria-setsize") ?? "—";
attrPosinset.textContent = focusedEl.getAttribute("aria-posinset") ?? "—";
} else {
attrSelected.textContent = "—";
attrSetsize.textContent = "—";
attrPosinset.textContent = "—";
}
}
// =============================================================================
// Accessible-dependent UI visibility
// =============================================================================
const interactiveUi = document.querySelectorAll("[data-requires-interactive]");
function updateInteractiveUi() {
for (const el of interactiveUi) {
el.classList.toggle("is-disabled", !interactiveEnabled);
}
}
// =============================================================================
// Create list
// =============================================================================
let activeDescObserver = null;
export function createList() {
if (list) {
list.destroy();
list = null;
}
if (activeDescObserver) {
activeDescObserver.disconnect();
activeDescObserver = null;
}
const container = document.getElementById("list-container");
container.innerHTML = "";
list = vlist({
container: "#list-container",
ariaLabel: "Employee directory",
item: {
height: ITEM_HEIGHT,
template: itemTemplate,
},
items: users,
accessible: interactiveEnabled,
}).build();
list.on("scroll", updateInfo);
list.on("range:change", updateInfo);
list.on("velocity:change", ({ velocity }) => {
stats.onVelocity(velocity);
updateInfo();
});
// Watch aria-activedescendant on the root → update inspector + footer
const root = container.querySelector(".vlist");
if (root) {
activeDescObserver = new MutationObserver(() => {
updateInspector();
updateContext();
});
activeDescObserver.observe(root, {
attributes: true,
attributeFilter: ["aria-activedescendant"],
});
}
updateInspector();
updateInteractiveUi();
updateInfo();
updateContext();
}
// =============================================================================
// Info bar — right side (contextual)
// =============================================================================
const infoFocused = document.getElementById("info-focused");
const infoPosinset = document.getElementById("info-posinset");
export function updateContext() {
const container = document.getElementById("list-container");
const root = container && container.querySelector(".vlist");
if (!root) return;
const activeId = root.getAttribute("aria-activedescendant");
if (activeId) {
const el = root.querySelector(`#${CSS.escape(activeId)}`);
infoFocused.textContent = activeId;
infoPosinset.textContent = el?.getAttribute("aria-posinset") ?? "—";
} else {
infoFocused.textContent = "—";
infoPosinset.textContent = "—";
}
}
// =============================================================================
// Interactive toggle
// =============================================================================
export function setInteractive(enabled) {
interactiveEnabled = enabled;
createList();
}
// =============================================================================
// Initialise
// =============================================================================
createList();
/* Accessibility — example-specific styles
Item styles use .vlist-item directly (no wrapper div needed),
matching the basic example pattern. */
/* ============================================================================
List container
============================================================================ */
#list-container {
height: 600px;
}
/* ============================================================================
Item — styles live on .vlist-item (no inner wrapper)
============================================================================ */
.vlist-item {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
}
.item__avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.item__text {
flex: 1;
min-width: 0;
}
.item__name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item__email {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item__index {
font-size: 12px;
color: var(--text-muted);
min-width: 48px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ============================================================================
ARIA inspector value — truncate long strings (aria-label, activedescendant)
============================================================================ */
.attr-truncate {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: right;
}
/* ============================================================================
Disabled state — fades out UI when interactive is off
============================================================================ */
.is-disabled {
opacity: 0.25;
pointer-events: none;
user-select: none;
transition: opacity 0.2s ease-out;
}
/* ============================================================================
Hint text — small description below a control
============================================================================ */
.ui-hint {
margin: 4px 0 0;
padding: 0;
font-size: 11px;
color: var(--text-muted);
opacity: 0.6;
line-height: 1.4;
transition: opacity 0.2s ease-out;
}
<div class="container">
<header>
<h1>Accessibility -</h1>
<p class="description">
WAI-ARIA listbox — <code>role="listbox"</code> on the root,
<code>role="option"</code> on every item,
<code>aria-setsize</code> / <code>aria-posinset</code> for
positional context, <code>aria-activedescendant</code> for focus
tracking, and <code>aria-selected</code> for selection state. Tab
into the list and use arrow keys, Space, or Enter to interact.
Disable <code>interactive</code> to remove all keyboard navigation.
</p>
</header>
<div class="split-layout">
<div class="split-main">
<h2 class="sr-only">Items</h2>
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Interactive toggle -->
<section class="ui-section">
<h3 class="ui-title">Keyboard Navigation</h3>
<div class="ui-row">
<span class="ui-label">interactive</span>
<label class="ui-switch">
<input
type="checkbox"
id="toggle-interactive"
checked
/>
<span class="ui-switch__track"></span>
</label>
</div>
<p class="ui-hint" id="interactive-hint">
Arrow keys move focus between items
</p>
</section>
<!-- ARIA Inspector -->
<section class="ui-section">
<h3 class="ui-title">ARIA Inspector</h3>
<div class="ui-row no-margin">
<span class="ui-label">role</span>
<span class="ui-value" id="attr-role">—</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">aria-label</span>
<span class="ui-value attr-truncate" id="attr-label"
>—</span
>
</div>
<div class="ui-row no-margin">
<span class="ui-label">tabindex</span>
<span class="ui-value" id="attr-tabindex">—</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">activedescendant</span>
<span
class="ui-value attr-truncate"
id="attr-activedescendant"
>—</span
>
</div>
<div class="ui-row no-margin">
<span class="ui-label">aria-selected</span>
<span class="ui-value" id="attr-selected">—</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">aria-setsize</span>
<span class="ui-value" id="attr-setsize">—</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">aria-posinset</span>
<span class="ui-value" id="attr-posinset">—</span>
</div>
</section>
<!-- Keyboard reference -->
<section class="ui-section" data-requires-interactive>
<h3 class="ui-title">Keyboard</h3>
<div class="ui-row no-margin">
<span class="ui-label">Tab</span>
<span class="ui-value">Focus list</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">↑ / ↓</span>
<span class="ui-value">Move focus</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">Space / Enter</span>
<span class="ui-value">Toggle select</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">PgUp / PgDn</span>
<span class="ui-value">Jump by page</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">Home / End</span>
<span class="ui-value">First / Last</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">items</span>
</span>
</div>
<div class="example-info__right">
<span class="example-info__stat">
focused <strong id="info-focused">—</strong>
</span>
<span class="example-info__stat">
posinset <strong id="info-posinset">—</strong>
</span>
</div>
</div>
</div>