/ Examples
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>