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.
Toggle selection off to test baseline ARIA without the feature.
Source
// Accessibility — WAI-ARIA listbox pattern demonstration
// Shows: role="listbox" / role="option", aria-setsize, aria-posinset,
// aria-activedescendant, aria-selected, keyboard navigation, and selection.
// The ARIA inspector and announcement log update live as you interact.
// Toggle selection off ("None") to test baseline ARIA without the feature.
import { vlist, withSelection } from "vlist";
import { makeUsers } from "../../src/data/people.js";
import { createStats } from "../stats.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 selectionMode = "single"; // "none" | "single" | "multiple"
// =============================================================================
// Template
// =============================================================================
export const itemTemplate = (user, index) => `
<div class="item__avatar" style="background:${user.color}">${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 footer (progress, velocity, visible/total)
// =============================================================================
export const stats = createStats({
getList: () => list,
getTotal: () => TOTAL,
getItemHeight: () => ITEM_HEIGHT,
container: "#list-container",
});
// =============================================================================
// 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 = "—";
}
}
// =============================================================================
// Announcement Log — mirrors what a screen reader would hear from the live
// region. Captures text changes in the aria-live element created by
// withSelection and displays them in the visible log panel.
// =============================================================================
const logList = document.getElementById("announcement-log-list");
let logCount = 0;
const MAX_LOG_ENTRIES = 50;
function logAnnouncement(text) {
if (!logList || !text) return;
logCount++;
const li = document.createElement("li");
li.className = "announcement-log__entry";
li.innerHTML = `<span class="announcement-log__number">${logCount}</span>${escapeHtml(text)}`;
// Prepend so newest is on top
logList.prepend(li);
// Trim old entries
while (logList.children.length > MAX_LOG_ENTRIES) {
logList.lastElementChild.remove();
}
}
function clearLog() {
if (!logList) return;
logList.innerHTML = "";
logCount = 0;
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// =============================================================================
// Live region observer — watches the sr-only live region for text changes
// =============================================================================
let liveRegionObserver = null;
function observeLiveRegion(root) {
if (liveRegionObserver) {
liveRegionObserver.disconnect();
liveRegionObserver = null;
}
const liveRegion = root.querySelector("[aria-live]");
if (!liveRegion) return;
liveRegionObserver = new MutationObserver(() => {
const text = liveRegion.textContent.trim();
if (text) logAnnouncement(text);
});
liveRegionObserver.observe(liveRegion, {
childList: true,
characterData: true,
subtree: true,
});
}
// =============================================================================
// Selection-dependent UI visibility
// =============================================================================
const selectionUi = document.querySelectorAll("[data-requires-selection]");
function updateSelectionUi() {
const enabled = selectionMode !== "none";
for (const el of selectionUi) {
el.classList.toggle("is-disabled", !enabled);
}
}
// =============================================================================
// Create list
// =============================================================================
let activeDescObserver = null;
export function createList() {
if (list) {
list.destroy();
list = null;
}
if (activeDescObserver) {
activeDescObserver.disconnect();
activeDescObserver = null;
}
if (liveRegionObserver) {
liveRegionObserver.disconnect();
liveRegionObserver = null;
}
const container = document.getElementById("list-container");
container.innerHTML = "";
const builder = vlist({
container: "#list-container",
ariaLabel: "Employee directory",
item: {
height: ITEM_HEIGHT,
template: itemTemplate,
},
items: users,
});
// Only add selection feature when mode is not "none"
if (selectionMode !== "none") {
builder.use(withSelection({ mode: selectionMode }));
}
list = builder.build();
list.on("scroll", stats.scheduleUpdate);
list.on("range:change", stats.scheduleUpdate);
list.on("velocity:change", ({ velocity }) => stats.onVelocity(velocity));
// Wire selection events only when the feature is active
if (selectionMode !== "none") {
list.on("selection:change", ({ selected }) => {
updateSelectionCount(selected);
updateInspector();
});
}
// 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"],
});
// Observe the live region for announcements (only exists with selection)
if (selectionMode !== "none") {
observeLiveRegion(root);
}
}
updateInspector();
updateSelectionUi();
stats.update();
updateContext();
updateSelectionCount([]);
}
// =============================================================================
// Footer — right side (contextual)
// =============================================================================
const ftFocused = document.getElementById("ft-focused");
const ftPosinset = document.getElementById("ft-posinset");
const ftSelection = document.getElementById("ft-selection");
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)}`);
ftFocused.textContent = activeId;
ftPosinset.textContent = el?.getAttribute("aria-posinset") ?? "—";
} else {
ftFocused.textContent = "—";
ftPosinset.textContent = "—";
}
}
function updateSelectionCount(selected) {
if (!ftSelection) return;
const count = Array.isArray(selected) ? selected.length : 0;
ftSelection.textContent = String(count);
}
// =============================================================================
// Selection mode switching
// =============================================================================
export function setSelectionMode(mode) {
selectionMode = mode;
clearLog();
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;
color: white;
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;
}
/* ============================================================================
Selection mode toggle — fill variant for the button group
============================================================================ */
.panel-btn-group--fill {
display: flex;
width: 100%;
}
.panel-btn-group--fill .panel-btn {
flex: 1;
}
/* ============================================================================
Announcement log — mirrors aria-live output for visual inspection
============================================================================ */
.announcement-log {
list-style: none;
margin: 0;
padding: 0;
max-height: 80px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border, #374151) transparent;
}
.announcement-log::-webkit-scrollbar {
width: 4px;
}
.announcement-log::-webkit-scrollbar-track {
background: transparent;
}
.announcement-log::-webkit-scrollbar-thumb {
background-color: var(--border, #374151);
border-radius: 2px;
}
.announcement-log__entry {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 0;
font-size: 12px;
color: var(--text-muted, #9ca3af);
border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
animation: log-fade-in 0.2s ease-out;
}
.announcement-log__entry:last-child {
border-bottom: none;
}
.announcement-log__number {
flex-shrink: 0;
min-width: 20px;
font-size: 10px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--accent, #3b82f6);
opacity: 0.6;
}
.announcement-log__empty {
margin: 0;
padding: 8px 0;
font-size: 12px;
color: var(--text-muted, #9ca3af);
opacity: 0.5;
font-style: italic;
}
/* Hide empty message when log has entries */
.announcement-log:not(:empty) + .announcement-log__empty {
display: none;
}
/* ============================================================================
Panel title hint — secondary label (e.g. "aria-live")
============================================================================ */
.panel-title__hint {
font-size: 11px;
font-weight: 400;
color: var(--text-muted, #9ca3af);
opacity: 0.7;
margin-left: 6px;
}
/* ============================================================================
Animation
============================================================================ */
@keyframes log-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================================================
Disabled state — fades out selection-dependent UI when mode is "none"
============================================================================ */
.is-disabled {
opacity: 0.25;
pointer-events: none;
user-select: none;
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.
Toggle selection off to test baseline ARIA without the feature.
</p>
</header>
<div class="split-layout">
<div class="split-main">
<div id="list-container"></div>
</div>
<aside class="split-panel">
<!-- Selection Mode -->
<section class="panel-section">
<h3 class="panel-title">Selection</h3>
<div class="panel-row">
<div class="panel-btn-group panel-btn-group--fill">
<button
class="panel-btn"
id="btn-mode-none"
aria-pressed="false"
>
None
</button>
<button
class="panel-btn panel-btn--active"
id="btn-mode-single"
aria-pressed="true"
>
Single
</button>
<button
class="panel-btn"
id="btn-mode-multiple"
aria-pressed="false"
>
Multiple
</button>
</div>
</div>
</section>
<!-- ARIA Inspector -->
<section class="panel-section">
<h3 class="panel-title">ARIA Inspector</h3>
<div class="panel-row no-margin">
<span class="panel-label">role</span>
<span class="panel-value" id="attr-role">—</span>
</div>
<div class="panel-row no-margin">
<span class="panel-label">aria-label</span>
<span class="panel-value attr-truncate" id="attr-label"
>—</span
>
</div>
<div class="panel-row no-margin">
<span class="panel-label">tabindex</span>
<span class="panel-value" id="attr-tabindex">—</span>
</div>
<div class="panel-row no-margin" data-requires-selection>
<span class="panel-label">activedescendant</span>
<span
class="panel-value attr-truncate"
id="attr-activedescendant"
>—</span
>
</div>
<div class="panel-row no-margin" data-requires-selection>
<span class="panel-label">aria-selected</span>
<span class="panel-value" id="attr-selected">—</span>
</div>
<div class="panel-row no-margin">
<span class="panel-label">aria-setsize</span>
<span class="panel-value" id="attr-setsize">—</span>
</div>
<div class="panel-row no-margin">
<span class="panel-label">aria-posinset</span>
<span class="panel-value" id="attr-posinset">—</span>
</div>
</section>
<!-- Keyboard reference -->
<section class="panel-section">
<h3 class="panel-title">Keyboard</h3>
<div class="panel-row no-margin">
<span class="panel-label">Tab</span>
<span class="panel-value">Focus list</span>
</div>
<div class="panel-row no-margin" data-requires-selection>
<span class="panel-label">↑ / ↓</span>
<span class="panel-value">Move focus</span>
</div>
<div class="panel-row no-margin" data-requires-selection>
<span class="panel-label">Space / Enter</span>
<span class="panel-value">Toggle select</span>
</div>
<div class="panel-row no-margin" data-requires-selection>
<span class="panel-label">Home / End</span>
<span class="panel-value">First / Last</span>
</div>
</section>
<!-- Announcements log -->
<section class="panel-section" data-requires-selection>
<h3 class="panel-title">
Announcements
<span class="panel-title__hint">aria-live</span>
</h3>
<ul
class="announcement-log"
id="announcement-log-list"
aria-label="Screen reader announcement log"
></ul>
<p class="announcement-log__empty" id="announcement-log-empty">
Select an item to see announcements
</p>
</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" data-requires-selection>
selected <strong id="ft-selection">0</strong>
</span>
<span class="example-footer__stat" data-requires-selection>
focused <strong id="ft-focused">—</strong>
</span>
<span class="example-footer__stat" data-requires-selection>
posinset <strong id="ft-posinset">—</strong>
</span>
</div>
</footer>
</div>