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